<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <id>https://tracerzzz.cn/</id>
  <title type="text">A Craftsman</title>
  <subtitle type="text">tracerZzz&apos;blog</subtitle>
  <updated>2026-10-31T13:30:00.000Z</updated>
  <author><name>tracerZzz</name></author>
  <link rel="alternate" href="https://tracerzzz.cn/"/>
  <link rel="self" href="https://tracerzzz.cn/atom.xml"/>
  <generator uri="https://github.com/CuteLeaf/Firefly">Firefly v6.16.8</generator>
    <entry>
      <id>https://tracerzzz.cn/posts/go-gmp-scheduler/</id>
      <title type="text">一家多诊室的医院：用类比理解 Go 的 GMP 调度</title>
      <published>2026-10-30T16:40:00.000Z</published>
      <updated>2026-10-30T16:40:00.000Z</updated>
      <author><name>tracerZzz</name></author>
      <link rel="alternate" href="https://tracerzzz.cn/posts/go-gmp-scheduler/"/>
      <summary type="text">用&quot;医院看病&quot;的类比讲清楚 Go 的 GMP 调度：G、M、P、全局队列、偷任务、抢占、系统调用、netpoller 和 sysmon 各自做什么，顺带回顾用户态和内核态，对比 Python asyncio、Node.js 和 Go 的调度分层，最后用两个小程序亲眼看看调度器在干什么。</summary>
      <content type="html"><![CDATA[<p></p><figure><img alt="一家多诊室的医院：四间诊室 P0 到 P3 各有一位医生，P3 门口没人，正从 P2 门口偷走一半病人；下面的大厅是全局队列，角落里的巡查员是 sysmon" loading="lazy" width="1920" height="1080" src="/_astro/hospital.CbnWzOEF_29WGbp.webp" /><figcaption>一家多诊室的医院：四间诊室 P0 到 P3 各有一位医生，P3 门口没人，正从 P2 门口偷走一半病人；下面的大厅是全局队列，角落里的巡查员是 sysmon</figcaption></figure><p></p>
<p>我之前在<a href="/posts/python-asyncio-concurrent-queries/">用 Python 协程把成本数据产出提速十倍</a>那篇里，用”医院看病”讲过 Python 的异步：一个医生、一个诊室、病人去做检查时医生先看下一位。这次就沿着这个类比往下走：<strong>Go 相当于把这家诊所扩建成了一家多诊室的医院</strong>。</p>
<blockquote><p>说明：文中涉及的运行时细节，我对照了 Go 1.24 的运行时源码（<code>runtime/proc.go</code> 等）；两个示例程序是在 Go 1.24 上实际跑过的。不同版本的实现细节可能有出入。</p></blockquote>
<section><h2>一、整体印象：一个进程，多个线程<a href="#一整体印象一个进程多个线程"><span>#</span></a></h2><p>先把最重要的结构定下来：<strong>一个 Go 程序只有一个进程，G、M、P 全部都在这一个进程里</strong>。</p><ul>
<li><strong>G</strong> 是 goroutine，数量可以有几十万个；</li>
<li><strong>M</strong> 是操作系统线程，一个进程里有多个；</li>
<li><strong>P</strong> 是逻辑处理器，只是这个进程内存里的一个数据结构，数量默认等于 CPU 核数；</li>
<li><strong>全局队列</strong>是调度器里的一个队列，所有线程都可能访问它，所以要加锁。</li>
</ul><p>P 之间、M 之间要协作，靠的是直接读写同一个进程里的共享内存，配合锁和原子操作，不涉及进程间通信，也没有主进程、子进程之分。</p></section>
<section><h2>二、用户态和内核态<a href="#二用户态和内核态"><span>#</span></a></h2><p>要理解 goroutine 为什么”轻”，得先回到操作系统。</p><p>CPU 有权限等级，内核跑在最高权限，普通程序跑在最低权限。程序不能直接操作硬件、不能碰别的进程的内存，凡是读写文件、收发网络包、创建线程这类事情，都要通过<strong>系统调用</strong>请内核来做。进入内核的方式有三种：</p><ul>
<li><strong>系统调用</strong>：程序主动请求，比如 <code>read</code>、<code>write</code>、<code>epoll_wait</code>；</li>
<li><strong>异常</strong>：程序执行出了状况，比如访问的内存页还没准备好（缺页）；</li>
<li><strong>中断</strong>：硬件发起的，比如网卡收到数据、时钟定时响一下。操作系统就是靠时钟中断定期打断线程，实现线程的抢占式调度。</li>
</ul><p>进出内核要保存寄存器、切换栈、做权限检查，还会让 CPU 缓存变”冷”。更贵的是<strong>线程切换</strong>：要由内核的调度器决定换谁，保存和恢复完整的线程上下文。</p>

<table><thead><tr><th></th><th>线程切换</th><th>goroutine 切换</th></tr></thead><tbody><tr><td>谁来调度</td><td>内核</td><td>Go 运行时，在用户态完成</td></tr><tr><td>要不要进内核</td><td>要</td><td>不要</td></tr><tr><td>保存的内容</td><td>完整的线程上下文</td><td>少量寄存器（程序计数器、栈指针等）</td></tr><tr><td>大致开销</td><td>微秒级</td><td>百纳秒级</td></tr><tr><td>栈</td><td>Linux 默认按 8MB 预留</td><td>初始 2KB，按需增长</td></tr></tbody></table><p>放到医院里：</p><ul>
<li><strong>用户态是门诊区</strong>：诊室、候诊椅、大厅，医生和病人在这里自由活动；</li>
<li><strong>内核态是后勤区</strong>：药房、档案室、检验科、收发室、人事科。医生不能自己进去，只能到窗口填单子，由院方办理；</li>
<li><strong>线程切换</strong>是人事科给医生换岗：交接、换衣服，要经过人事科；</li>
<li><strong>goroutine 切换</strong>是同一个医生换下一个病人：放下这份病历夹，拿起下一份，在诊室里自己就能完成。</li>
</ul><p>Go 调度器很多设计的出发点，就是<strong>尽量少让医生去后勤窗口干等</strong>。</p></section>
<section><h2>三、G、M、P 三个角色<a href="#三gmp-三个角色"><span>#</span></a></h2>

<table><thead><tr><th>Go</th><th>医院</th><th>说明</th></tr></thead><tbody><tr><td>G（goroutine）</td><td>病人，手里拿着自己的病历夹</td><td>病历夹里记着看到哪一步了，就是 G 的栈和上下文</td></tr><tr><td>M（machine，操作系统线程）</td><td>医生</td><td>真正执行代码的永远是线程</td></tr><tr><td>P（processor，逻辑处理器）</td><td>诊室</td><td>医生必须进了诊室才能看病</td></tr><tr><td>P 的本地队列</td><td>诊室门口的候诊椅</td><td>最多 256 把椅子，只有本诊室叫号，基本不用加锁</td></tr><tr><td>P 的 <code>runnext</code></td><td>门口的”优先位”</td><td>只能坐一个人，下一个就看他</td></tr><tr><td>全局队列</td><td>大厅总候诊区</td><td>归护士站管，谁来领人都得排队（加锁）</td></tr></tbody></table><p>整家医院运转起来是这样的，每个地方都标上了对应的 Go 模块，后面几节会逐个展开：</p><p></p><figure><img alt="医院运转图：门诊区是用户态，诊室是 P，里面有优先位 runnext、候诊椅本地队列和医生 M；大厅是全局队列，休息室是空闲的 M，值班医生是阻塞在 epoll_wait 的 M，巡查员是 sysmon；后勤区是内核态，检验科是 epoll，档案室是阻塞的系统调用，人事科是内核的线程调度" loading="lazy" width="960" height="680" src="/_astro/gmp-hospital.DVDzbKOB_Z18gWuo.svg" /><figcaption>医院运转图：门诊区是用户态，诊室是 P，里面有优先位 runnext、候诊椅本地队列和医生 M；大厅是全局队列，休息室是空闲的 M，值班医生是阻塞在 epoll_wait 的 M，巡查员是 sysmon；后勤区是内核态，检验科是 epoll，档案室是阻塞的系统调用，人事科是内核的线程调度</figcaption></figure><p></p><p>几个要点：</p><ul>
<li><strong>P 的数量默认等于 CPU 核数</strong>（<code>GOMAXPROCS</code>）。4 核就是 4 间诊室，同一时刻最多 4 个医生在看病，正好把 4 个核用满。</li>
<li><strong>P 不绑定某个具体的核</strong>。P 更像一张”允许执行 Go 代码的通行证”，线程拿到 P 才能跑 goroutine；至于这个线程跑在哪个核上，是操作系统决定的。</li>
<li><strong>M 可以比 P 多</strong>。有的医生被拉去后勤窗口等着了（阻塞在系统调用里），诊室会交给别的医生，所以医生总数会超过诊室数。</li>
<li><strong>P 自己不”做”任何事</strong>。它只是存着队列和一些缓存，真正执行代码的是 M。准确的说法是”M 拿着 P 去找 G”。</li>
</ul><div><div><div><div><span><p>一个 Go 进程</p></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>P2</p></span></div><div><span><p>runnext: 空</p></span></div><div><span><p>本地队列: 空</p></span></div><div><span><p>P1</p></span></div><div><span><p>runnext: 空</p></span></div><div><span><p>本地队列: G4</p></span></div><div><span><p>P0</p></span></div><div><span><p>runnext: G9</p></span></div><div><span><p>本地队列: G1 G2 G3</p></span></div><div><span><p>全局队列（加锁访问）</p></span></div><div><span><p>M0 线程：正在跑 G7</p></span></div><div><span><p>M1 线程：正在跑 G8</p></span></div><div><span><p>M2 线程：没活了，准备去偷</p></span></div><div><span><p>操作系统：把线程调度到 CPU 核上</p></span></div>
</div><div><div><span><p>一个 Go 进程</p></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>P2</p></span></div><div><span><p>runnext: 空</p></span></div><div><span><p>本地队列: 空</p></span></div><div><span><p>P1</p></span></div><div><span><p>runnext: 空</p></span></div><div><span><p>本地队列: G4</p></span></div><div><span><p>P0</p></span></div><div><span><p>runnext: G9</p></span></div><div><span><p>本地队列: G1 G2 G3</p></span></div><div><span><p>全局队列（加锁访问）</p></span></div><div><span><p>M0 线程：正在跑 G7</p></span></div><div><span><p>M1 线程：正在跑 G8</p></span></div><div><span><p>M2 线程：没活了，准备去偷</p></span></div><div><span><p>操作系统：把线程调度到 CPU 核上</p></span></div>
</div></div></div></section>
<section><h2>四、新建的 G 放在哪<a href="#四新建的-g-放在哪"><span>#</span></a></h2><p><code>go f()</code> 创建的 G，放进<strong>当前这个 P</strong> 里：</p><ol>
<li>先放进 <code>runnext</code>，原来占着 <code>runnext</code> 的那个 G 被挤到本地队列尾部；</li>
<li>本地队列最多 256 个，满了就把一半（128 个）连同新的这个一起挪到全局队列。</li>
</ol><p>挪到全局队列后，<strong>不会再主动分给别的 P</strong>，而是别的 P 没活时自己来拿，或者直接去偷。</p><p><strong>goroutine 是先进先出，还是后加入的先执行？</strong></p><p>要分清两样东西。每个 G 自己的<strong>调用栈</strong>当然是后进先出，和任何语言一样。但<strong>等待执行的队列</strong>，本地队列和全局队列都是先进先出。唯一的例外是 <code>runnext</code>：<strong>最新创建的那一个 G 会插队</strong>，下一个就执行它。</p><p>为什么要让它插队？刚创建的 G 往往和当前 G 关系密切，比如刚往 channel 里发了数据，新起的 G 马上要收，立刻执行能用上还热着的 CPU 缓存。为了防止两个 G 互相通过 <code>runnext</code> 插队、把队列里其他 G 饿死，插队的 G 会继承当前 G 剩下的时间片，而不是拿一个新的。</p><p>用医院说：病人挂号后坐到挂号诊室门口；刚转过来的病人坐优先位，下一个就看；门口坐满了，一半人被送去大厅。</p></section>
<section><h2>五、M 怎么找下一个 G<a href="#五m-怎么找下一个-g"><span>#</span></a></h2><p>当前的 G 主动让出（等 channel、等锁、等网络）、被抢占，或者执行结束时，M 就进入运行时的调度函数，按固定顺序找下一个 G：</p><div><div><div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>找到了</p></span></div><div><span><p>找到了</p></span></div><div><span><p>找到了</p></span></div><div><span><p>找到了</p></span></div><div><span><p>找到了</p></span></div><div><span><p>找到了</p></span></div><div><span>1. 每调度 61 次，先从全局队列拿一个</span></div><div><span>2. 看自己 P 的 runnext</span></div><div><span>3. 看自己 P 的本地队列</span></div><div><span>4. 看全局队列（加锁，顺便多拿一批回本地）</span></div><div><span>5. 非阻塞地查一下网络轮询器</span></div><div><span>6. 随机顺序去别的 P 偷一半</span></div><div><span>7. 都没有：交出 P，M 休眠</span></div><div><span><p>在当前 M 上执行这个 G<br /><br />让出或被抢占后，回到第 1 步</p></span></div>
</div><div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>找到了</p></span></div><div><span><p>找到了</p></span></div><div><span><p>找到了</p></span></div><div><span><p>找到了</p></span></div><div><span><p>找到了</p></span></div><div><span><p>找到了</p></span></div><div><span>1. 每调度 61 次，先从全局队列拿一个</span></div><div><span>2. 看自己 P 的 runnext</span></div><div><span>3. 看自己 P 的本地队列</span></div><div><span>4. 看全局队列（加锁，顺便多拿一批回本地）</span></div><div><span>5. 非阻塞地查一下网络轮询器</span></div><div><span>6. 随机顺序去别的 P 偷一半</span></div><div><span>7. 都没有：交出 P，M 休眠</span></div><div><span><p>在当前 M 上执行这个 G<br /><br />让出或被抢占后，回到第 1 步</p></span></div>
</div></div></div><p>越靠前的越便宜：本地队列基本不用加锁，全局队列要加锁，去偷别人的最贵。</p><p><strong>这个循环是每个 M 自己转的</strong>。没有一个”总调度员”线程在给大家分活，4 个 P 就有 4 个 M 各自转这个循环，只在访问全局队列或者偷别人时才有交集。“调度器”指的是这套规则和共享的数据结构，而不是一个单独的线程。用医院说，就是<strong>每个医生看完一个病人，自己去找下一个</strong>。</p><section><h3>为什么每 61 次要先查全局队列<a href="#为什么每-61-次要先查全局队列"><span>#</span></a></h3><p>M 平时优先看本地队列。如果本地队列一直有活，比如两个 G 不停地互相创建新的 G，M 就永远轮不到全局队列，那里的 G 会被饿死。运行时源码里的注释说的正是这个情况。所以每调度 61 次，强制先从全局队列拿一个。</p><p>为什么是 61：不能太频繁，全局队列要加锁；也不能太稀疏，否则全局队列等得太久。61 是个质数，不容易和程序里某种固定周期的行为撞在一起。</p><p>医院里就是：医生每看 61 个门口的病人，去大厅叫一次号。</p></section><section><h3>偷任务是怎么偷的<a href="#偷任务是怎么偷的"><span>#</span></a></h3><p>M 走到第 6 步时：</p><ol>
<li>把所有 P 按随机顺序排一遍，避免一群空闲的 M 同时去抢同一个 P；</li>
<li>从目标 P 的本地队列里拿走<strong>一半</strong>（有 3 个就拿 2 个），靠原子操作修改对方队列的头指针完成，不用加锁，对方照常工作；</li>
<li>偷到的 G 放进自己的本地队列，马上执行其中一个；</li>
<li>一轮没偷到就再来，最多 4 轮，最后一轮还会尝试偷对方的 <code>runnext</code>。</li>
</ol><p>为什么是一半：偷一个，过一会儿又空了，又得来偷；偷全部，对方空了，换成对方来偷你。一半能让两边大致平衡。</p><p>另外，同时在”找活”的 M 数量有上限，防止一大堆空闲线程空转抢 CPU。</p><p>医院里就是：医生门口没人了，先去大厅看看，再问检验科有没有出结果的，还是没有，就去隔壁诊室门口领走一半等着的病人。</p></section></section>
<section><h2>六、抢占：某个病人看得太久<a href="#六抢占某个病人看得太久"><span>#</span></a></h2><p>如果一个 G 一直在算，不让出，会不会霸占 P？</p><p>后台有个监控线程叫 <strong>sysmon</strong>，它发现某个 G 连续运行超过 <strong>10ms</strong>，就会要求它让出：</p><ol>
<li>先做个标记，等它自己停。编译器在几乎每个函数开头都插了一段检查栈空间的代码，G 下次调用函数时会看到这个标记，主动进入调度。这是 Go 1.14 以前唯一的办法，缺点是如果循环里根本不调用函数，就永远看不到。</li>
<li>Go 1.14 起，还会<strong>给这个线程发一个 <code>SIGURG</code> 信号</strong>。线程收到信号后，运行时检查当前位置是否安全（GC 能否正确识别栈上哪些是指针、是不是正在执行不能被打断的运行时代码），安全就保存现场、跳进调度器；不安全就下次再试。选 <code>SIGURG</code> 是因为普通程序几乎不用它。</li>
</ol><p>被抢占的 G 会放进<strong>全局队列</strong>重新排队，之后轮到它时从被打断的那条指令继续执行，业务代码感知不到。</p><p>医院里就是：某个病人看了太久，巡查员敲门提醒，这个病人先拿着病历夹回大厅排队。</p><p><strong>这样做好吗？</strong> 好处不只是公平：</p><ul>
<li><strong>GC 需要所有 G 停下来</strong>。GC 的两次短暂停要求所有 goroutine 都到达安全点。在没有异步抢占的年代，一个不调用函数的死循环会让暂停永远等不到它，整个程序卡死；</li>
<li><strong>防止饿死、降低延迟</strong>，计算密集的 G 不会让同一个 P 上排队的请求一直等。</li>
</ul><p>代价：保存和恢复寄存器、缓存变冷（但 10ms 对 CPU 来说已经很长了）；被抢占的 G 要重新排队；信号可能打断正在进行的系统调用，让它返回 <code>EINTR</code>。标准库内部都做了重试，但调用 C 库或没处理 <code>EINTR</code> 的代码可能出问题，可以用 <code>GODEBUG=asyncpreemptoff=1</code> 关掉异步抢占来排查。</p><p>另外，抢占只管”轮流用 CPU”，不管数据安全，并发读写共享数据该加锁还得加锁。</p><section><h3>亲眼看看抢占<a href="#亲眼看看抢占"><span>#</span></a></h3><p>只留一个 P，起一个不调用任何函数的死循环：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>package</span><span> </span><span>main</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>import</span><span> (</span></div></div><div><div><div>4</div></div><div><span>  </span><span>"fmt"</span></div></div><div><div><div>5</div></div><div><span>  </span><span>"runtime"</span></div></div><div><div><div>6</div></div><div><span>  </span><span>"time"</span></div></div><div><div><div>7</div></div><div><span>)</span></div></div><div><div><div>8</div></div><div>
</div></div><div><div><div>9</div></div><div><span>func</span><span> </span><span>main</span><span>() {</span></div></div><div><div><div>10</div></div><div><span>  </span><span>runtime</span><span>.</span><span>GOMAXPROCS</span><span>(</span><span>1</span><span>) </span><span>// 只留一个 P</span></div></div><div><div><div>11</div></div><div>
</div></div><div><div><div>12</div></div><div><span>  </span><span>go</span><span> </span><span>func</span><span>() {</span></div></div><div><div><div>13</div></div><div><span>    </span><span>for</span><span> {</span></div></div><div><div><div>14</div></div><div><span><span>    </span></span><span>} </span><span>// 纯计算的死循环，不调用任何函数</span></div></div><div><div><div>15</div></div><div><span><span>  </span></span><span>}()</span></div></div><div><div><div>16</div></div><div>
</div></div><div><div><div>17</div></div><div><span>  </span><span>time</span><span>.</span><span>Sleep</span><span>(</span><span>100</span><span><span> </span><span>*</span><span> </span></span><span>time</span><span>.</span><span>Millisecond</span><span>) </span><span>// 让出 P，死循环开始跑</span></div></div><div><div><div>18</div></div><div><span>  </span><span>fmt</span><span>.</span><span>Println</span><span>(</span><span>"main 又跑起来了：死循环被抢占了"</span><span>)</span></div></div><div><div><div>19</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p><code>main</code> 睡眠时让出了唯一的 P，死循环开始独占它。100ms 后 <code>main</code> 该醒了，但它需要 P 才能执行：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>$</span><span> </span><span>go</span><span> </span><span>run</span><span> </span><span>main.go</span></div></div><div><div><div>2</div></div><div><span>main</span><span> </span><span>又跑起来了：死循环被抢占了</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span>$</span><span> </span><span>GODEBUG=asyncpreemptoff=</span><span>1</span><span> </span><span>timeout</span><span> </span><span>3</span><span> </span><span>go</span><span> </span><span>run</span><span> </span><span>main.go</span></div></div><div><div><div>5</div></div><div><span>$</span><span> </span><span>echo</span><span> </span><span>$?</span></div></div><div><div><div>6</div></div><div><span>124</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>默认情况下，死循环被信号抢占，<code>main</code> 正常打印退出。关掉异步抢占后，死循环里没有任何函数调用，没机会看到抢占标记，<code>main</code> 永远拿不回 P，直到被 <code>timeout</code> 杀掉（退出码 124）。这就是 Go 1.14 以前的行为。</p></section></section>
<section><h2>七、系统调用阻塞了怎么办<a href="#七系统调用阻塞了怎么办"><span>#</span></a></h2><p>读文件这类系统调用会让线程卡在内核里。这时 G 和 M 是绑在一起的：内核只认识线程，线程卡在内核里，就是在替这个 G 等结果。问题是 M 手上还拿着 P，P 门口的其他 G 怎么办？</p><div><div><div>M2sysmonPM1G（读文件）M2sysmonPM1G（读文件）原来的 P 空闲就拿回否则找任意空闲 P都没有就把 G 放进全局队列，M1 休眠alt[很快返回][卡住了一段时间]发起 read 系统调用标记"系统调用中"，暂时不交出直接拿回 P，接着执行 G发现 P 一直处在系统调用中，且还有 G 在等把 P 交给 M2（没有空闲 M 就新建）继续执行队列里的其他 G系统调用返回，带着 G 找 P
</div><div>M2sysmonPM1G（读文件）M2sysmonPM1G（读文件）原来的 P 空闲就拿回否则找任意空闲 P都没有就把 G 放进全局队列，M1 休眠alt[很快返回][卡住了一段时间]发起 read 系统调用标记"系统调用中"，暂时不交出直接拿回 P，接着执行 G发现 P 一直处在系统调用中，且还有 G 在等把 P 交给 M2（没有空闲 M 就新建）继续执行队列里的其他 G系统调用返回，带着 G 找 P
</div></div></div><p>几个细节：</p><ul>
<li><strong>不急着交出 P</strong>。大多数系统调用很快就返回，每次都转交，开销反而更大。对已知一定会长时间阻塞的调用，运行时会在进入时就直接交出。</li>
<li><strong>为什么拿不回原来的 P</strong>：卡住的这段时间里，P 已经被转交给别的 M 在用了，这正是转交的目的。转交出去就是别人的，没有”用完还给原主”的约定。</li>
<li><strong>结果属于 G，不属于 M</strong>。系统调用返回的数据写在 G 自己的栈和上下文里，M 只是执行者，不保存业务状态。所以 G 放进全局队列后，任何一个 M 取到它，都能从系统调用的下一行继续，业务代码感知不到中间换了线程。一个 goroutine 一生中可能在好几个线程上运行过。</li>
<li><strong>为什么不让 M 原地等 P</strong>：等待本身也要占一个线程，而 G 放进全局队列后谁都能执行它，效果一样，还更省资源。</li>
</ul><p>医院里就是：病人需要医生亲自陪着做手术，医生被带走了。巡查员发现诊室空着，把钥匙交给另一位医生。手术结束，原来的医生带着病人回来，诊室已经有人了，就找间空诊室；一间都没有，就把病人送回大厅，自己去休息室。<strong>检查结果写在病人的病历里，不在医生脑子里</strong>，所以谁接手都能接着看。</p></section>
<section><h2>八、网络 IO：netpoller 和值班医生<a href="#八网络-ionetpoller-和值班医生"><span>#</span></a></h2><p>网络 IO 不走上面那条路，而是交给<strong>网络轮询器</strong>（netpoller），底层在 Linux 上是 epoll。</p><p>先说 epoll 本身：<code>epoll_ctl</code> 把连接注册进内核里的 epoll 实例，网卡收到数据后，内核协议栈把数据放进 socket 缓冲区，同时通过注册时挂上的回调，把这个连接加入<strong>就绪列表</strong>；<code>epoll_wait</code> 只返回就绪的连接，不用遍历全部连接。</p><p>Go 里调用 <code>conn.Read</code> 时：</p><ol>
<li>先非阻塞地读一次，有数据就直接返回；</li>
<li>没数据，就把这个 G 挂起，挂在这个连接上（运行时用边缘触发的方式注册 epoll），M 去执行别的 G；</li>
<li>数据到了，某个 M 在找活的第 5 步查 epoll 时发现它就绪，把挂起的 G 放回运行队列；</li>
<li>这个 G 被调度到时，从 <code>Read</code> 那一行继续，读到数据返回。</li>
</ol><p>开发者看到的是一个阻塞的 <code>Read</code>，实际上线程一刻也没有被阻塞。这就是 Go 能用同步的写法得到异步性能的原因。</p><section><h3>为什么只有一个 M 阻塞在 epoll_wait<a href="#为什么只有一个-m-阻塞在-epoll_wait"><span>#</span></a></h3><p>每个 M 找活时都会查 epoll，但同一时刻只有一个 M 阻塞在 <code>epoll_wait</code> 上，区别在于查法：</p><ul>
<li><strong>非阻塞地查</strong>（超时为 0，看一眼就走）：任何 M 找活时都会做；</li>
<li><strong>阻塞地等</strong>（一直等到有事件或定时器到期）：同一时刻<strong>只允许一个 M</strong>。</li>
</ul><p>M 走完所有步骤都没找到活，先把 P 交回空闲列表，再用原子操作去抢一个全局标记（运行时里是 <code>sched.lastpoll</code>），抢到的那个去阻塞等待 epoll，其他 M 看到已经有人在等，直接休眠。</p><p>原因有三：一个就够了，epoll 实例只有一个，多个线程守着也拿不到更多东西；避免<strong>惊群</strong>，多个线程阻塞在同一个 epoll 上，一个事件可能把它们都唤醒，最后只有一个拿到，其他白白经历一次内核态的唤醒和切换；而且这个 M 已经交出了 P，阻塞着也不影响别人干活。</p><p>事件来了，这个 M 被唤醒，拿到就绪的 G，抢一个空闲的 P 来执行，活多的话再唤醒其他 M。</p><p>医院里就是：样本送去外面化验，病人在结果通知中心登记一次就回候诊区，医生去看别人。全院都闲下来时，医生们交还诊室钥匙去休息室，<strong>只留一位值班医生守着检验科的电话</strong>。结果一到，他把病人叫回来，人多再去叫醒其他医生。全院医生都守着同一部电话没有意义，电话一响全被吵醒，最后只有一个人接。</p><p>顺带一提，<strong>文件读写不走 netpoller</strong>。在 Linux 上，普通文件对 epoll 来说永远是”可读”的，起不到等待的作用，所以 Go 把它当成普通的阻塞系统调用，靠第七节的转交 P 来兜底。</p></section></section>
<section><h2>九、sysmon：院务巡查员<a href="#九sysmon院务巡查员"><span>#</span></a></h2><p>前面几次出现的 sysmon，是运行时在程序启动时自动创建的后台线程，不需要配置，也关不掉。它<strong>不需要 P</strong>，所以不占执行 Go 代码的名额。繁忙时大约每 20 微秒检查一次，长时间空闲时逐渐放慢到每 10 毫秒一次。</p><p>它做的事基本都是”兜底”：</p><ul>
<li>抢占运行超过 10ms 的 G；</li>
<li>拿走卡在系统调用里的 P，转交给别的 M；</li>
<li>超过 10ms 没人查过 epoll，就主动非阻塞地查一次，防止就绪的 G 没人捡；</li>
<li>超过 2 分钟没发生 GC，就强制触发一次。</li>
</ul><p>正常的调度都是每个 M 自己找活，sysmon 负责处理那些”没人主动处理就会卡住”的情况。</p></section>
<section><h2>十、动手观察调度器<a href="#十动手观察调度器"><span>#</span></a></h2><p><code>GODEBUG=schedtrace=毫秒数</code> 会让运行时定期打印调度器的状态。起 100 个纯计算的 goroutine，固定 4 个 P：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>package</span><span> </span><span>main</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>import</span><span> (</span></div></div><div><div><div>4</div></div><div><span>  </span><span>"sync"</span></div></div><div><div><div>5</div></div><div><span>  </span><span>"time"</span></div></div><div><div><div>6</div></div><div><span>)</span></div></div><div><div><div>7</div></div><div>
</div></div><div><div><div>8</div></div><div><span>func</span><span> </span><span>work</span><span>() {</span></div></div><div><div><div>9</div></div><div><span>  </span><span>sum</span><span><span> </span><span>:=</span><span> </span></span><span>0</span></div></div><div><div><div>10</div></div><div><span>  </span><span>for</span><span> </span><span>i</span><span><span> </span><span>:=</span><span> </span></span><span>0</span><span>; </span><span>i</span><span><span> </span><span>&lt;</span><span> </span></span><span>50_000_000</span><span>; </span><span>i</span><span><span>++</span><span> {</span></span></div></div><div><div><div>11</div></div><div><span>    </span><span>sum</span><span><span> </span><span>+=</span><span> </span></span><span>i</span></div></div><div><div><div>12</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>13</div></div><div><span>  </span><span>_</span><span><span> </span><span>=</span><span> </span></span><span>sum</span></div></div><div><div><div>14</div></div><div><span>}</span></div></div><div><div><div>15</div></div><div>
</div></div><div><div><div>16</div></div><div><span>func</span><span> </span><span>main</span><span>() {</span></div></div><div><div><div>17</div></div><div><span>  </span><span>var</span><span> </span><span>wg</span><span> </span><span>sync</span><span>.</span><span>WaitGroup</span></div></div><div><div><div>18</div></div><div><span>  </span><span>for</span><span> </span><span>i</span><span><span> </span><span>:=</span><span> </span></span><span>0</span><span>; </span><span>i</span><span><span> </span><span>&lt;</span><span> </span></span><span>100</span><span>; </span><span>i</span><span><span>++</span><span> {</span></span></div></div><div><div><div>19</div></div><div><span>    </span><span>wg</span><span>.</span><span>Add</span><span>(</span><span>1</span><span>)</span></div></div><div><div><div>20</div></div><div><span>    </span><span>go</span><span> </span><span>func</span><span>() {</span></div></div><div><div><div>21</div></div><div><span>      </span><span>defer</span><span> </span><span>wg</span><span>.</span><span>Done</span><span>()</span></div></div><div><div><div>22</div></div><div><span>      </span><span>work</span><span>()</span></div></div><div><div><div>23</div></div><div><span><span>    </span></span><span>}()</span></div></div><div><div><div>24</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>25</div></div><div><span>  </span><span>wg</span><span>.</span><span>Wait</span><span>()</span></div></div><div><div><div>26</div></div><div><span>  </span><span>time</span><span>.</span><span>Sleep</span><span>(</span><span>10</span><span><span> </span><span>*</span><span> </span></span><span>time</span><span>.</span><span>Millisecond</span><span>)</span></div></div><div><div><div>27</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>$ GOMAXPROCS=4 GODEBUG=schedtrace=200 go run main.go</span></div></div><div><div><div>2</div></div><div><span>SCHED 0ms: gomaxprocs=4 idleprocs=1 threads=5 spinningthreads=1 needspinning=0 idlethreads=1 runqueue=0 [0 0 0 0]</span></div></div><div><div><div>3</div></div><div><span>SCHED 201ms: gomaxprocs=4 idleprocs=0 threads=5 spinningthreads=0 needspinning=1 idlethreads=0 runqueue=12 [11 11 10 12]</span></div></div><div><div><div>4</div></div><div><span>SCHED 406ms: gomaxprocs=4 idleprocs=0 threads=5 spinningthreads=0 needspinning=1 idlethreads=0 runqueue=3 [0 2 1 2]</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>各字段的意思：</p><ul>
<li><code>gomaxprocs=4</code>：4 个 P；</li>
<li><code>idleprocs</code>：空闲的 P，跑起来之后是 0，四间诊室都在看病；</li>
<li><code>threads=5</code>：一共 5 个线程，4 个干活的 M，再加 sysmon 等；</li>
<li><code>spinningthreads</code>：正在”找活”的 M，刚启动时有 1 个；</li>
<li><code>runqueue=12</code>：全局队列里有 12 个 G；</li>
<li><code>[11 11 10 12]</code>：4 个 P 的本地队列各有多少个 G。</li>
</ul><p>可以看到，100 个 G 并没有挤在一个 P 上：一部分在全局队列，其余分散在 4 个 P 的本地队列，随着时间推移被逐步消化。线程数一直是 5 个，不会因为 goroutine 多就创建更多线程。</p></section>
<section><h2>十一、和 Python、Node.js 对比<a href="#十一和-pythonnodejs-对比"><span>#</span></a></h2>

<table><thead><tr><th></th><th>Python asyncio</th><th>Node.js</th><th>Go</th></tr></thead><tbody><tr><td>进程</td><td>一个</td><td>一个</td><td>一个</td></tr><tr><td>执行业务代码的线程</td><td>一个</td><td>一个</td><td>多个 M，大约等于 P 的数量</td></tr><tr><td>调度</td><td>一个事件循环</td><td>一个分阶段的事件循环</td><td>每个 M 各自找活，共享全局队列、互相偷</td></tr><tr><td>什么时候切换</td><td>代码写到 <code>await</code></td><td>一个回调执行完</td><td>等 channel、锁、网络、系统调用，或运行超过 10ms 被抢占</td></tr><tr><td>网络 IO</td><td>epoll，就绪后执行回调</td><td>epoll，就绪后执行回调</td><td>epoll，就绪后唤醒挂起的 G</td></tr><tr><td>代码写法</td><td>必须 <code>async/await</code></td><td>回调或 <code>async/await</code></td><td>普通同步写法</td></tr><tr><td>能否用满多核</td><td>不能，要配合多进程</td><td>不能，要配合多进程或 worker</td><td>能</td></tr></tbody></table><p>三者在用户态和内核态里的分层如下：</p><div><div><div><div><span><p>内核态（三者共用）</p></span></div><div><span><p>用户态</p></span></div><div><span><p>Go</p></span></div><div><span><p>Node.js</p></span></div><div><span><p>Python asyncio</p></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>系统调用</p></span></div><div><span><p>系统调用</p></span></div><div><span><p>系统调用</p></span></div><div><span></span></div><div><span></span></div><div><span><p>很多协程<br /><br />await 处主动让出</p></span></div><div><span><p>一个事件循环<br /><br />执行就绪的回调</p></span></div><div><span><p>1 个线程<br /><br />多线程也受 GIL 限制</p></span></div><div><span><p>回调和 Promise<br /><br />一个跑完才轮下一个</p></span></div><div><span><p>libuv 事件循环<br /><br />按阶段执行回调</p></span></div><div><span><p>1 个 JS 线程<br /><br />外加文件 IO 线程池</p></span></div><div><span><p>很多 goroutine<br /><br />运行时自动挂起、抢占</p></span></div><div><span><p>GMP 调度<br /><br />P 的数量 = 核数</p></span></div><div><span><p>多个 M 线程<br /><br />同时执行 Go 代码</p></span></div><div><span><p>epoll：告诉用户态哪些连接就绪了</p></span></div><div><span><p>线程调度：决定哪个线程在哪个核上跑</p></span></div><div><span><p>CPU 核 0 ～ 核 3</p></span></div>
</div><div><div><span><p>内核态（三者共用）</p></span></div><div><span><p>用户态</p></span></div><div><span><p>Go</p></span></div><div><span><p>Node.js</p></span></div><div><span><p>Python asyncio</p></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>系统调用</p></span></div><div><span><p>系统调用</p></span></div><div><span><p>系统调用</p></span></div><div><span></span></div><div><span></span></div><div><span><p>很多协程<br /><br />await 处主动让出</p></span></div><div><span><p>一个事件循环<br /><br />执行就绪的回调</p></span></div><div><span><p>1 个线程<br /><br />多线程也受 GIL 限制</p></span></div><div><span><p>回调和 Promise<br /><br />一个跑完才轮下一个</p></span></div><div><span><p>libuv 事件循环<br /><br />按阶段执行回调</p></span></div><div><span><p>1 个 JS 线程<br /><br />外加文件 IO 线程池</p></span></div><div><span><p>很多 goroutine<br /><br />运行时自动挂起、抢占</p></span></div><div><span><p>GMP 调度<br /><br />P 的数量 = 核数</p></span></div><div><span><p>多个 M 线程<br /><br />同时执行 Go 代码</p></span></div><div><span><p>epoll：告诉用户态哪些连接就绪了</p></span></div><div><span><p>线程调度：决定哪个线程在哪个核上跑</p></span></div><div><span><p>CPU 核 0 ～ 核 3</p></span></div>
</div></div></div><p>横着看三列、竖着看三层：</p><ul>
<li><strong>任务切换都在用户态</strong>：协程、回调、goroutine 之间切换都不用进内核，区别只在”谁决定切”：Python 和 Node 靠代码写到 <code>await</code> 或回调结束，Go 由运行时自动决定；</li>
<li><strong>差别集中在线程这一层</strong>：Python 和 Node 最终只有一个线程执行业务代码，内核只能把它放到一个核上；Go 有多个 M，内核可以把它们分到多个核上同时跑；</li>
<li><strong>内核这一层三者完全一样</strong>：都通过系统调用使用 epoll 等待网络事件，线程都交给内核调度到 CPU 上。</li>
</ul><p>套回医院：Python asyncio 是<strong>只有一个诊室、一个医生</strong>的诊所，医生自己当分诊台，病人只有自己说”我去检查了”才让出位置，医生要是被病人拉去陪检查，整个诊所就停了。Go 是<strong>多诊室的医院</strong>：诊室数等于核数，医生可以比诊室多，有大厅和护士站统一调配，有巡查员兜底，医生闲了还能去别的诊室领病人。病人只管拿好自己的病历夹，换哪个医生、在哪个诊室看，都不影响结果。</p></section>
<section><h2>十二、速查清单<a href="#十二速查清单"><span>#</span></a></h2><ul>
<li>一个 Go 程序是<strong>单进程多线程</strong>，G、M、P 都在这个进程里；</li>
<li>P 的数量默认等于核数，是线程执行 Go 代码的通行证，不绑定具体的核；</li>
<li>新建的 G 进当前 P 的 <code>runnext</code>，本地队列最多 256 个，满了一半挪到全局队列；</li>
<li>队列整体先进先出，只有最新创建的那个能通过 <code>runnext</code> 插队；</li>
<li>每个 M 自己找活：<code>runnext</code> 和本地队列 → 全局队列 → 非阻塞查 netpoller → 随机偷别的 P 一半 → 交出 P 休眠；</li>
<li>每调度 61 次先查一次全局队列，防止饿死；</li>
<li>sysmon 发现 G 运行超过 10ms，Go 1.14 起通过 <code>SIGURG</code> 信号异步抢占，被抢占的 G 进全局队列；</li>
<li>系统调用阻塞时 P 会被转交给别的 M，返回后的 G 交给任意一个有 P 的 M 继续执行，结果在 G 的上下文里；</li>
<li>网络 IO 挂到 netpoller，同一时刻只有一个 M 阻塞在 <code>epoll_wait</code>，避免惊群；文件读写不走 netpoller；</li>
<li>sysmon 是不需要 P 的后台线程，负责抢占、回收系统调用中的 P、兜底查 epoll、定期触发 GC；</li>
<li>想观察调度器：<code>GODEBUG=schedtrace=1000</code>；排查异步抢占相关的问题：<code>GODEBUG=asyncpreemptoff=1</code>。</li>
</ul><p>参考：</p><ul>
<li><a href="https://github.com/golang/go/blob/go1.24.7/src/runtime/proc.go" target="_blank">Go 运行时调度器源码 runtime/proc.go（Go 1.24.7）</a></li>
<li><a href="https://github.com/golang/go/blob/go1.24.7/src/runtime/runtime2.go" target="_blank">Go 运行时数据结构 runtime/runtime2.go（Go 1.24.7）</a></li>
<li><a href="https://github.com/golang/go/blob/go1.24.7/src/runtime/netpoll_epoll.go" target="_blank">Go 运行时 epoll 实现 runtime/netpoll_epoll.go（Go 1.24.7）</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://tracerzzz.cn/posts/mac-hotspot-split-routing/</id>
      <title type="text">让 Mac 和 iPhone 指定网站走 eSIM：sing-box 分流实录</title>
      <published>2026-10-27T11:40:00.000Z</published>
      <updated>2026-10-31T13:30:00.000Z</updated>
      <author><name>tracerZzz</name></author>
      <link rel="alternate" href="https://tracerzzz.cn/posts/mac-hotspot-split-routing/"/>
      <summary type="text">家里 Wi-Fi 访问一部分境外网站不稳定，iPhone 上的境外 eSIM 访问正常但流量很少。这篇记录怎么让 Mac（通过手机热点）和 iPhone 自己都只把指定网站分给 eSIM，其他流量照常走 Wi-Fi。最后两台设备共用一份 sing-box 配置，用 iCloud 同步，规则全部来自社区规则集，基本不用手动维护。附完整配置、一路补过的漏洞和换设备时的快速清单。</summary>
      <content type="html"><![CDATA[<blockquote><p><strong>2026-10-31 更新</strong>：Mac 也从 Clash Verge Rev 换成了 sing-box，和 iPhone 共用同一份配置，放在 iCloud 云盘里同步；手写的域名规则全部换成了社区规则集；记录了换过来之后国内网站全部打不开的排查过程。Clash 方案保留在第三节作为过程记录，完整配置以第五节为准。</p></blockquote>
<p>我的 iPhone 是双卡：主号是国内卡，日常用；另外有一张境外 eSIM，访问境外网站很顺畅，但流量很少。家里的 Wi-Fi 访问 Claude、GitHub、Google 这类境外网站不稳定，Steam 商店也时好时坏。</p>
<p>我想要的效果是，不管在 Mac 上还是在 iPhone 上：</p>
<ul>
<li><strong>指定的境外网站</strong>走 eSIM</li>
<li><strong>其他所有流量</strong>（国内网站、游戏下载、系统更新）照常走家里 Wi-Fi</li>
<li>eSIM 流量能省则省，不能有流量在我不知道的时候偷偷走 eSIM</li>
</ul>
<p>最后两台设备都用 sing-box 实现了，而且用的是<strong>同一份配置文件</strong>。中间先后试过静态路由和 Clash Verge Rev，也补了好几轮漏洞，这篇把过程和原理讲清楚，最后附上换设备时的快速操作清单。</p>
<section><h2>一、先搞懂几个概念<a href="#一先搞懂几个概念"><span>#</span></a></h2><section><h3>网卡名和 IP 不是一回事<a href="#网卡名和-ip-不是一回事"><span>#</span></a></h3><p>终端里运行 <code>ifconfig</code>，会看到 <code>en0</code>、<code>en6</code> 这样的名字，下面跟着一串 IP 地址。</p><ul>
<li><strong>网卡名</strong>是系统给硬件起的编号。<code>en0</code> 一般就是这台 Mac 的 Wi-Fi 网卡，不管连哪个网络都叫 <code>en0</code>。</li>
<li><strong>IP 地址</strong>是当前连着的那个网络临时分给你的。连家里 Wi-Fi 是 <code>192.168.1.x</code>，连 iPhone 热点就是 <code>172.20.10.x</code>。</li>
</ul><p>可以把网卡名理解成<strong>门牌号</strong>，IP 是<strong>这次住进来的人</strong>：门牌固定，住的人随网络变化。所以配置分流时不能认 IP。</p></section><section><h3>Mac 要让两个网络同时在线<a href="#mac-要让两个网络同时在线"><span>#</span></a></h3><p>Mac 只有一块 Wi-Fi 网卡，同一时间只能连一个 Wi-Fi。如果 Wi-Fi 连的是手机热点，那所有流量都在走热点，没有东西可以「分」。</p><p>正确的连法是：</p><ul>
<li><strong>Mac 的 Wi-Fi</strong> 连家里路由器，做主网络</li>
<li><strong>iPhone 用数据线</strong>连 Mac，打开个人热点，Mac 上会多出一块叫「iPhone USB」的网卡</li>
</ul><p>两块网卡各连各的，这才叫同时在线。两个网络都在线时，系统默认把流量交给<strong>服务顺序排在前面</strong>的那个。在「系统设置 → 网络」里点列表下方的「⋯」→「设定服务顺序」，要保证 <strong>Wi-Fi 排在 iPhone USB 上面</strong>。</p></section><section><h3>iPhone 双卡同一时间只有一张卡能上网<a href="#iphone-双卡同一时间只有一张卡能上网"><span>#</span></a></h3><p>iPhone 的双卡是「双卡双待」：两张卡都能待机接电话，但<strong>同一时间只有一张卡连着数据网络</strong>，就是「设置 → 蜂窝网络 → 蜂窝数据」里选中的那张。另一张卡的数据通道根本没有建立。</p><p>这带来两个结论：</p><ul>
<li><strong>个人热点走的是当前的数据卡。</strong> 要让 Mac 通过热点用上 eSIM，数据卡就得是 eSIM。</li>
<li><strong>iPhone 做不到「按网站自动换卡」。</strong> 切换数据卡要好几秒，而且会断开所有连接，App 也没有权限切换。iPhone 上能做的分流是 <strong>Wi-Fi 和蜂窝之间</strong>的分流：在家时 iPhone 同时有家里 Wi-Fi 和蜂窝数据（eSIM）两个出口，按网站选其中一个。</li>
</ul><p>另外，<strong>iPhone 的个人热点只共享蜂窝数据，从不共享它连着的 Wi-Fi</strong>。热点共享出去的流量也不经过 iPhone 上的 sing-box，Mac 的分流由 Mac 自己的 sing-box 负责，两边互不干扰。</p><p>整体是这样：</p>

<table><thead><tr><th>设备</th><th>国内网站</th><th>境外网站</th><th>谁负责分流</th></tr></thead><tbody><tr><td>iPhone（连着家里 Wi-Fi）</td><td>家里 Wi-Fi</td><td>蜂窝 → eSIM</td><td>iPhone 上的 sing-box</td></tr><tr><td>Mac</td><td>家里 Wi-Fi</td><td>iPhone USB 热点 → eSIM</td><td>Mac 上的 sing-box</td></tr></tbody></table><p>前提只有一个：<strong>在家时 iPhone 的数据卡要设成 eSIM</strong>。出门没有 Wi-Fi 时，把数据卡切回主号就行。</p></section></section>
<section><h2>二、Mac 上的第一次尝试：静态路由<a href="#二mac-上的第一次尝试静态路由"><span>#</span></a></h2><p>最直接的想法是改路由表：告诉系统「去这几个 IP 的流量，交给热点的网关」。</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>sudo</span><span> </span><span>route</span><span> </span><span>add</span><span> </span><span>-net</span><span> </span><span>160.79.104.0/23</span><span> </span><span>172.20.10.1</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>172.20.10.1</code> 是 iPhone 热点的网关。我写了个脚本，定时解析域名、给解析出来的 IP 加路由，后来还做成了一个带开关的 SwiftUI 小应用。对 Claude 这种域名少、IP 段固定的服务确实好用，但扩展到更多网站时问题就来了：</p><ol>
<li><strong>只能按 IP 分，不能按域名分。</strong> 路由表只认 IP，每个域名都要先解析成 IP 再加路由，子域名也得一个个写，<code>*.google.com</code> 这种通配写法用不了。</li>
<li><strong>DNS 解析结果不一定可用。</strong> 有些境外域名在家里网络上解析，拿到的地址本身就连不上，把这种地址路由到热点也没用。</li>
<li><strong>CDN 的 IP 会共用和变化。</strong> 同一个 CDN 节点可能同时服务很多网站，解析结果还经常变。</li>
</ol><p>几十个网站还能凑合，常用的境外网站有几千个域名，这条路走不通。</p></section>
<section><h2>三、Mac 的第二个方案：Clash Verge Rev<a href="#三mac-的第二个方案clash-verge-rev"><span>#</span></a></h2><p>Clash Verge Rev 是一个开源的网络规则工具，内核叫 mihomo。很多人用它配合代理服务，但它也可以<strong>完全不用代理</strong>，只做一件事：按规则决定每条连接从哪块网卡出去。这一节记录它的原理，现在的 sing-box 方案也是同样的思路。</p><section><h3>数据是怎么走的<a href="#数据是怎么走的"><span>#</span></a></h3><p>打开「虚拟网卡模式」（TUN）后，系统里会多出一块虚拟网卡，所有流量都先进这块网卡，再由规则决定去向：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>应用（浏览器、Steam、Claude）</span></div></div><div><div><div>2</div></div><div><span><span>        </span></span><span>│  所有 DNS 查询和连接</span></div></div><div><div><div>3</div></div><div><span><span>        </span></span><span>▼</span></div></div><div><div><div>4</div></div><div><span>虚拟网卡 utun（mihomo 接管）</span></div></div><div><div><div>5</div></div><div><span><span>        </span></span><span>│  按域名 / IP 匹配规则</span></div></div><div><div><div>6</div></div><div><span><span>        </span></span><span>▼</span></div></div><div><div><div>7</div></div><div><span><span>   </span></span><span>┌────┴─────────────┐</span></div></div><div><div><div>8</div></div><div><span><span>   </span></span><span>▼                  ▼</span></div></div><div><div><div>9</div></div><div><span>Wi-Fi（DIRECT）     iPhone USB（热点 → eSIM）</span></div></div><div><div><div>10</div></div><div><span>国内网站、下载        Claude、GitHub、Google、Steam 商店</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>以打开 google.com 为例：</p><ol>
<li><strong>浏览器问「google.com 的 IP 是多少」。</strong> 这个 DNS 查询被虚拟网卡截住，mihomo 不去真查，而是立刻回一个假地址（比如 <code>198.18.0.5</code>），同时记下「<code>198.18.0.5</code> 就是 google.com」。这叫 <strong>fake-ip 模式</strong>。</li>
<li><strong>浏览器去连 <code>198.18.0.5</code>。</strong> 这个连接也进了虚拟网卡。</li>
<li><strong>mihomo 查回域名，匹配规则。</strong> 根据假 IP 查回 <code>google.com</code>，命中「Google 走热点」的规则。</li>
<li><strong>mihomo 从热点网卡发起真正的连接。</strong> 它先经热点用海外 DNS 查到真实地址，再连过去，然后在两头之间搬运数据。</li>
</ol><p>这个设计解决了静态路由的三个问题：能<strong>按域名分流</strong>，子域名自动覆盖；需要走热点的域名<strong>经热点用海外 DNS 解析</strong>，结果可靠；还有社区维护的<strong>现成规则集</strong>，几千个域名开箱即用。mihomo 自己发出去的连接不会又被吸回虚拟网卡，因为它发起连接时会直接<strong>绑定在指定的真实网卡上</strong>。</p></section><section><h3>用了几天后的问题<a href="#用了几天后的问题"><span>#</span></a></h3><p>Clash 方案能用，但有几处不顺手：</p><ul>
<li><strong>要写死热点网卡名。</strong> 配置里的出口要写 <code>interface-name: en6</code> 这样的网卡名，每台电脑、每部手机可能都不一样，我为此还写了一个自动识别网卡、改配置的脚本。</li>
<li><strong>和 iPhone 是两套配置。</strong> iPhone 上只能用 sing-box，两边的配置格式完全不同，加一个网站要改两个地方。</li>
<li><strong>后台多一个常驻服务。</strong> 虚拟网卡模式要装一个系统服务，加上界面程序，对这台 2016 年的老 MacBook Pro 来说不算轻。</li>
</ul><p>后来发现 sing-box 在 Mac 上也能用同一份配置，而且不用写网卡名，就整体换了过去。</p></section></section>
<section><h2>四、最终方案：一份 sing-box 配置，两台设备共用<a href="#四最终方案一份-sing-box-配置两台设备共用"><span>#</span></a></h2><section><h3>两个出口<a href="#两个出口"><span>#</span></a></h3><p><a href="https://sing-box.sagernet.org/" target="_blank">sing-box</a> 从 1.11 版开始，可以给直连出口<a href="https://sing-box.sagernet.org/configuration/shared/dial/" target="_blank">指定网络类型</a>（<code>network_type</code>，可选 <code>wifi</code>、<code>cellular</code>、<code>ethernet</code>、<code>other</code>），这项功能支持苹果平台的图形客户端。配置里只有两个出口：</p>

<table><thead><tr><th>出口</th><th>写法</th><th>走哪里</th><th>用于</th></tr></thead><tbody><tr><td><code>default</code></td><td>普通直连</td><td>系统默认网络，在家就是 Wi-Fi</td><td>国内网站和其他所有流量</td></tr><tr><td><code>esim</code></td><td><code>network_type: ["cellular", "ethernet"]</code></td><td>蜂窝或有线网卡</td><td>境外网站列表、Claude、ChatGPT、GitHub、Google</td></tr></tbody></table></section><section><h3>为什么一份配置能在两台设备上用<a href="#为什么一份配置能在两台设备上用"><span>#</span></a></h3><p>关键就在 <code>esim</code> 出口的 <code>["cellular", "ethernet"]</code>：</p><ul>
<li><strong>在 iPhone 上</strong>，有蜂窝网络，<code>esim</code> 走蜂窝，也就是 eSIM。iPhone 没有有线网卡，<code>ethernet</code> 用不到。</li>
<li><strong>在 Mac 上</strong>，没有蜂窝网络，<code>esim</code> 就走有线网卡。iPhone 用数据线开热点时，macOS 把它当成一块有线网卡，于是 <code>esim</code> 正好走 iPhone 热点。家里网络走 Wi-Fi，和它分开。</li>
</ul><p>我在 Mac 上实际验证过：日志里能看到 <code>outbound/direct[esim]: dial en6</code>，<code>en6</code> 就是 iPhone USB 热点。这样配置里<strong>不用写网卡名</strong>，换手机、换电脑都不用改，Clash 时代那个识别网卡的脚本也就用不上了。</p><p>这个写法有两个前提：Mac 平时不能同时插网线上网，否则 sing-box 分不清哪块有线网卡是热点；以及第一节说的，服务顺序里 Wi-Fi 要排在 iPhone USB 前面。如果哪台 Mac 把 iPhone 热点识别成了别的类型，把 <code>ethernet</code> 换成 <code>other</code> 试试。</p></section><section><h3>为什么不用 Clash 的「智能回落」<a href="#为什么不用-clash-的智能回落"><span>#</span></a></h3><p>Clash 配置里有一个「热点优先」组：热点断了就自动回落到 Wi-Fi。sing-box 这份配置里没有。原因是这些境外网站在家里网络上本来就访问不稳定，回落过去也打不开，而为了探测热点通不通，还要定时消耗热点流量。所以干脆不做：热点断了，这些网站就打不开，插上线就恢复。</p></section><section><h3>用 iCloud 同步<a href="#用-icloud-同步"><span>#</span></a></h3><p>sing-box 的苹果客户端新建配置时可以选「iCloud」类型，读取 iCloud 云盘里 sing-box 文件夹中的 JSON 文件。我把配置放在那里，Mac 和 iPhone 读的是同一个文件，改一次两边都生效，具体见第八节。</p></section></section>
<section><h2>五、完整配置<a href="#五完整配置"><span>#</span></a></h2><p>保存为 <code>esim-split.json</code>，放进「iCloud 云盘 → sing-box」文件夹。sing-box 从 1.14 起支持在配置文件里写 <code>//</code> 注释，所以说明直接写在了文件里，复制整份就能用：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// ============================================================</span></div></div><div><div><div>2</div></div><div><span>//  esim-split.json：Mac 和 iPhone 共用的 sing-box 分流配置</span></div></div><div><div><div>3</div></div><div><span>//  放在「iCloud 云盘 → sing-box」里，两台设备都选 iCloud 类型的配置读取它</span></div></div><div><div><div>4</div></div><div><span>//</span></div></div><div><div><div>5</div></div><div><span>//  效果：</span></div></div><div><div><div>6</div></div><div><span>//    - Claude、ChatGPT、GitHub、Google、常用境外网站 → 走 eSIM</span></div></div><div><div><div>7</div></div><div><span>//        iPhone 上是蜂窝数据（数据卡设为 eSIM）</span></div></div><div><div><div>8</div></div><div><span>//        Mac 上是 iPhone 用数据线开的热点</span></div></div><div><div><div>9</div></div><div><span>//    - 国内网站和其他所有流量 → 走家里 Wi-Fi</span></div></div><div><div><div>10</div></div><div><span>//</span></div></div><div><div><div>11</div></div><div><span>//  改完这个文件后，要在 sing-box 里把开关关掉再打开才会生效</span></div></div><div><div><div>12</div></div><div><span>//  需要 sing-box 1.14 及以上（支持 // 注释和 http_clients 写法）</span></div></div><div><div><div>13</div></div><div><span>// ============================================================</span></div></div><div><div><div>14</div></div><div><span>{</span></div></div><div><div><div>15</div></div><div><span>  </span><span>// ---------- 日志 ----------</span></div></div><div><div><div>16</div></div><div><span>  </span><span>// warn：只记录警告和错误。排查问题时可以临时改成 info，看每条连接的去向</span></div></div><div><div><div>17</div></div><div><span>  </span><span>"log"</span><span>: {</span></div></div><div><div><div>18</div></div><div><span>    </span><span>"level"</span><span>: </span><span>"warn"</span></div></div><div><div><div>19</div></div><div><span><span>  </span></span><span>},</span></div></div><div><div><div>20</div></div><div>
</div></div><div><div><div>21</div></div><div><span>  </span><span>// ---------- DNS：域名解析 ----------</span></div></div><div><div><div>22</div></div><div><span>  </span><span>"dns"</span><span>: {</span></div></div><div><div><div>23</div></div><div><span>    </span><span>"servers"</span><span>: [</span></div></div><div><div><div>24</div></div><div><span><span>      </span></span><span>{</span></div></div><div><div><div>25</div></div><div><span>        </span><span>// 境外 DNS（Cloudflare 的加密 DNS），查询本身也经 esim 出口发出</span></div></div><div><div><div>26</div></div><div><span>        </span><span>// 这样拿到的是境外网络视角下真实可用的地址</span></div></div><div><div><div>27</div></div><div><span>        </span><span>"type"</span><span>: </span><span>"https"</span><span>,</span></div></div><div><div><div>28</div></div><div><span>        </span><span>"tag"</span><span>: </span><span>"dns-esim"</span><span>,</span></div></div><div><div><div>29</div></div><div><span>        </span><span>"server"</span><span>: </span><span>"1.1.1.1"</span><span>,</span></div></div><div><div><div>30</div></div><div><span>        </span><span>"detour"</span><span>: </span><span>"esim"</span></div></div><div><div><div>31</div></div><div><span><span>      </span></span><span>},</span></div></div><div><div><div>32</div></div><div><span><span>      </span></span><span>{</span></div></div><div><div><div>33</div></div><div><span>        </span><span>// 国内 DNS（阿里的加密 DNS），走默认网络，解析国内网站又快又准</span></div></div><div><div><div>34</div></div><div><span>        </span><span>"type"</span><span>: </span><span>"https"</span><span>,</span></div></div><div><div><div>35</div></div><div><span>        </span><span>"tag"</span><span>: </span><span>"dns-cn"</span><span>,</span></div></div><div><div><div>36</div></div><div><span>        </span><span>"server"</span><span>: </span><span>"223.5.5.5"</span></div></div><div><div><div>37</div></div><div><span><span>      </span></span><span>}</span></div></div><div><div><div>38</div></div><div><span><span>    </span></span><span>],</span></div></div><div><div><div>39</div></div><div><span>    </span><span>"rules"</span><span>: [</span></div></div><div><div><div>40</div></div><div><span><span>      </span></span><span>{</span></div></div><div><div><div>41</div></div><div><span>        </span><span>// 要走 eSIM 的域名只返回 IPv4 地址：境外 eSIM 漫游时只有 IPv4，</span></div></div><div><div><div>42</div></div><div><span>        </span><span>// 拿到 IPv6 地址会连不上（Claude 桌面版的长连接就会因此断开）</span></div></div><div><div><div>43</div></div><div><span>        </span><span>// 国内网站不受影响，照常可以用 IPv6</span></div></div><div><div><div>44</div></div><div><span>        </span><span>"query_type"</span><span>: [</span></div></div><div><div><div>45</div></div><div><span>          </span><span>"AAAA"</span></div></div><div><div><div>46</div></div><div><span><span>        </span></span><span>],</span></div></div><div><div><div>47</div></div><div><span>        </span><span>"rule_set"</span><span>: [</span></div></div><div><div><div>48</div></div><div><span>          </span><span>"anthropic"</span><span>,</span></div></div><div><div><div>49</div></div><div><span>          </span><span>"openai"</span><span>,</span></div></div><div><div><div>50</div></div><div><span>          </span><span>"overseas-list"</span><span>,</span></div></div><div><div><div>51</div></div><div><span>          </span><span>"github"</span><span>,</span></div></div><div><div><div>52</div></div><div><span>          </span><span>"google"</span></div></div><div><div><div>53</div></div><div><span><span>        </span></span><span>],</span></div></div><div><div><div>54</div></div><div><span>        </span><span>"action"</span><span>: </span><span>"predefined"</span></div></div><div><div><div>55</div></div><div><span><span>      </span></span><span>},</span></div></div><div><div><div>56</div></div><div><span><span>      </span></span><span>{</span></div></div><div><div><div>57</div></div><div><span>        </span><span>// 要走 eSIM 的域名，用境外 DNS 解析</span></div></div><div><div><div>58</div></div><div><span>        </span><span>"rule_set"</span><span>: [</span></div></div><div><div><div>59</div></div><div><span>          </span><span>"anthropic"</span><span>,</span></div></div><div><div><div>60</div></div><div><span>          </span><span>"openai"</span><span>,</span></div></div><div><div><div>61</div></div><div><span>          </span><span>"overseas-list"</span><span>,</span></div></div><div><div><div>62</div></div><div><span>          </span><span>"github"</span><span>,</span></div></div><div><div><div>63</div></div><div><span>          </span><span>"google"</span></div></div><div><div><div>64</div></div><div><span><span>        </span></span><span>],</span></div></div><div><div><div>65</div></div><div><span>        </span><span>"action"</span><span>: </span><span>"route"</span><span>,</span></div></div><div><div><div>66</div></div><div><span>        </span><span>"server"</span><span>: </span><span>"dns-esim"</span></div></div><div><div><div>67</div></div><div><span><span>      </span></span><span>}</span></div></div><div><div><div>68</div></div><div><span><span>    </span></span><span>],</span></div></div><div><div><div>69</div></div><div><span>    </span><span>// 其他所有域名都用国内 DNS</span></div></div><div><div><div>70</div></div><div><span>    </span><span>"final"</span><span>: </span><span>"dns-cn"</span></div></div><div><div><div>71</div></div><div><span><span>  </span></span><span>},</span></div></div><div><div><div>72</div></div><div>
</div></div><div><div><div>73</div></div><div><span>  </span><span>// ---------- 下载规则集用的 HTTP 客户端 ----------</span></div></div><div><div><div>74</div></div><div><span>  </span><span>// 规则集托管在 GitHub 上，家里网络访问不稳定，所以经 esim 出口下载</span></div></div><div><div><div>75</div></div><div><span>  </span><span>"http_clients"</span><span>: [</span></div></div><div><div><div>76</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>77</div></div><div><span>      </span><span>"tag"</span><span>: </span><span>"esim-http"</span><span>,</span></div></div><div><div><div>78</div></div><div><span>      </span><span>"detour"</span><span>: </span><span>"esim"</span></div></div><div><div><div>79</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>80</div></div><div><span><span>  </span></span><span>],</span></div></div><div><div><div>81</div></div><div>
</div></div><div><div><div>82</div></div><div><span>  </span><span>// ---------- 入口：虚拟网卡 ----------</span></div></div><div><div><div>83</div></div><div><span>  </span><span>// 系统所有流量都先进这块虚拟网卡，再由下面的规则决定从哪个出口出去</span></div></div><div><div><div>84</div></div><div><span>  </span><span>"inbounds"</span><span>: [</span></div></div><div><div><div>85</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>86</div></div><div><span>      </span><span>"type"</span><span>: </span><span>"tun"</span><span>,</span></div></div><div><div><div>87</div></div><div><span>      </span><span>"tag"</span><span>: </span><span>"tun-in"</span><span>,</span></div></div><div><div><div>88</div></div><div><span>      </span><span>// 虚拟网卡自己的地址，用的是不会和真实网络冲突的保留网段</span></div></div><div><div><div>89</div></div><div><span>      </span><span>"address"</span><span>: [</span></div></div><div><div><div>90</div></div><div><span>        </span><span>"172.19.0.1/30"</span><span>,</span></div></div><div><div><div>91</div></div><div><span>        </span><span>"fdfe:dcba:9876::1/126"</span></div></div><div><div><div>92</div></div><div><span><span>      </span></span><span>],</span></div></div><div><div><div>93</div></div><div><span>      </span><span>// 自动把系统默认路由指向虚拟网卡</span></div></div><div><div><div>94</div></div><div><span>      </span><span>"auto_route"</span><span>: </span><span>true</span><span>,</span></div></div><div><div><div>95</div></div><div><span>      </span><span>// 严格路由：防止流量绕过虚拟网卡直接出去</span></div></div><div><div><div>96</div></div><div><span>      </span><span>"strict_route"</span><span>: </span><span>true</span></div></div><div><div><div>97</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>98</div></div><div><span><span>  </span></span><span>],</span></div></div><div><div><div>99</div></div><div>
</div></div><div><div><div>100</div></div><div><span>  </span><span>// ---------- 出口 ----------</span></div></div><div><div><div>101</div></div><div><span>  </span><span>"outbounds"</span><span>: [</span></div></div><div><div><div>102</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>103</div></div><div><span>      </span><span>// 普通直连，跟随系统默认网络：在家就是 Wi-Fi</span></div></div><div><div><div>104</div></div><div><span>      </span><span>"type"</span><span>: </span><span>"direct"</span><span>,</span></div></div><div><div><div>105</div></div><div><span>      </span><span>"tag"</span><span>: </span><span>"default"</span></div></div><div><div><div>106</div></div><div><span><span>    </span></span><span>},</span></div></div><div><div><div>107</div></div><div><span><span>    </span></span><span>{</span></div></div><div><div><div>108</div></div><div><span>      </span><span>// 直连，但只走「蜂窝」或「有线」网络</span></div></div><div><div><div>109</div></div><div><span>      </span><span>//   iPhone：有蜂窝，走蜂窝数据（eSIM）；iPhone 没有有线网卡，ethernet 用不到</span></div></div><div><div><div>110</div></div><div><span>      </span><span>//   Mac：没有蜂窝，走有线网卡；iPhone 用数据线开的热点在 Mac 上就是一块有线网卡</span></div></div><div><div><div>111</div></div><div><span>      </span><span>// 所以同一份配置在两台设备上都正确，也不用写网卡名</span></div></div><div><div><div>112</div></div><div><span>      </span><span>// default 策略：只用这两类网络，它们不可用时不会偷偷改走 Wi-Fi</span></div></div><div><div><div>113</div></div><div><span>      </span><span>// 前提：Mac 不能同时插网线上网；如果某台 Mac 把热点识别成别的类型，把 ethernet 换成 other</span></div></div><div><div><div>114</div></div><div><span>      </span><span>"type"</span><span>: </span><span>"direct"</span><span>,</span></div></div><div><div><div>115</div></div><div><span>      </span><span>"tag"</span><span>: </span><span>"esim"</span><span>,</span></div></div><div><div><div>116</div></div><div><span>      </span><span>"network_strategy"</span><span>: </span><span>"default"</span><span>,</span></div></div><div><div><div>117</div></div><div><span>      </span><span>"network_type"</span><span>: [</span></div></div><div><div><div>118</div></div><div><span>        </span><span>"cellular"</span><span>,</span></div></div><div><div><div>119</div></div><div><span>        </span><span>"ethernet"</span></div></div><div><div><div>120</div></div><div><span><span>      </span></span><span>]</span></div></div><div><div><div>121</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>122</div></div><div><span><span>  </span></span><span>],</span></div></div><div><div><div>123</div></div><div>
</div></div><div><div><div>124</div></div><div><span>  </span><span>// ---------- 分流 ----------</span></div></div><div><div><div>125</div></div><div><span>  </span><span>"route"</span><span>: {</span></div></div><div><div><div>126</div></div><div><span>    </span><span>// 自动找到真实网卡，sing-box 自己发出的连接直接绑定到真实网卡，不会又绕回虚拟网卡</span></div></div><div><div><div>127</div></div><div><span>    </span><span>"auto_detect_interface"</span><span>: </span><span>true</span><span>,</span></div></div><div><div><div>128</div></div><div><span>    </span><span>// sing-box 自己需要解析域名时（比如连接出口），默认用国内 DNS</span></div></div><div><div><div>129</div></div><div><span>    </span><span>"default_domain_resolver"</span><span>: </span><span>"dns-cn"</span><span>,</span></div></div><div><div><div>130</div></div><div><span>    </span><span>// 下载规则集时默认用上面的 esim-http</span></div></div><div><div><div>131</div></div><div><span>    </span><span>"default_http_client"</span><span>: </span><span>"esim-http"</span><span>,</span></div></div><div><div><div>132</div></div><div>
</div></div><div><div><div>133</div></div><div><span>    </span><span>// ---------- 规则集：全部来自社区，每 7 天自动更新 ----------</span></div></div><div><div><div>134</div></div><div><span>    </span><span>// 来源：https://github.com/MetaCubeX/meta-rules-dat 的 sing 分支</span></div></div><div><div><div>135</div></div><div><span>    </span><span>// 源头列表：https://github.com/v2fly/domain-list-community 的 data 目录</span></div></div><div><div><div>136</div></div><div><span>    </span><span>// geosite = 按域名分组，geoip = 按 IP 段分组</span></div></div><div><div><div>137</div></div><div><span>    </span><span>"rule_set"</span><span>: [</span></div></div><div><div><div>138</div></div><div><span><span>      </span></span><span>{</span></div></div><div><div><div>139</div></div><div><span>        </span><span>// Claude / Anthropic 的全部域名，包括 Artifact 用的 claudeusercontent.com</span></div></div><div><div><div>140</div></div><div><span>        </span><span>"type"</span><span>: </span><span>"remote"</span><span>,</span></div></div><div><div><div>141</div></div><div><span>        </span><span>"tag"</span><span>: </span><span>"anthropic"</span><span>,</span></div></div><div><div><div>142</div></div><div><span>        </span><span>"format"</span><span>: </span><span>"binary"</span><span>,</span></div></div><div><div><div>143</div></div><div><span>        </span><span>"url"</span><span>: </span><span>"https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/sing/geo/geosite/anthropic.srs"</span><span>,</span></div></div><div><div><div>144</div></div><div><span>        </span><span>"update_interval"</span><span>: </span><span>"7d"</span></div></div><div><div><div>145</div></div><div><span><span>      </span></span><span>},</span></div></div><div><div><div>146</div></div><div><span><span>      </span></span><span>{</span></div></div><div><div><div>147</div></div><div><span>        </span><span>// ChatGPT / OpenAI 的全部域名，包括静态资源域名</span></div></div><div><div><div>148</div></div><div><span>        </span><span>"type"</span><span>: </span><span>"remote"</span><span>,</span></div></div><div><div><div>149</div></div><div><span>        </span><span>"tag"</span><span>: </span><span>"openai"</span><span>,</span></div></div><div><div><div>150</div></div><div><span>        </span><span>"format"</span><span>: </span><span>"binary"</span><span>,</span></div></div><div><div><div>151</div></div><div><span>        </span><span>"url"</span><span>: </span><span>"https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/sing/geo/geosite/openai.srs"</span><span>,</span></div></div><div><div><div>152</div></div><div><span>        </span><span>"update_interval"</span><span>: </span><span>"7d"</span></div></div><div><div><div>153</div></div><div><span><span>      </span></span><span>},</span></div></div><div><div><div>154</div></div><div><span><span>      </span></span><span>{</span></div></div><div><div><div>155</div></div><div><span>        </span><span>// 常用境外网站列表（上游文件名沿用项目原名）</span></div></div><div><div><div>156</div></div><div><span>        </span><span>// Steam 商店和社区、Telegram、X、YouTube、Grok 等都在里面</span></div></div><div><div><div>157</div></div><div><span>        </span><span>"type"</span><span>: </span><span>"remote"</span><span>,</span></div></div><div><div><div>158</div></div><div><span>        </span><span>"tag"</span><span>: </span><span>"overseas-list"</span><span>,</span></div></div><div><div><div>159</div></div><div><span>        </span><span>"format"</span><span>: </span><span>"binary"</span><span>,</span></div></div><div><div><div>160</div></div><div><span>        </span><span>"url"</span><span>: </span><span>"https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/sing/geo/geosite/gfw.srs"</span><span>,</span></div></div><div><div><div>161</div></div><div><span>        </span><span>"update_interval"</span><span>: </span><span>"7d"</span></div></div><div><div><div>162</div></div><div><span><span>      </span></span><span>},</span></div></div><div><div><div>163</div></div><div><span><span>      </span></span><span>{</span></div></div><div><div><div>164</div></div><div><span>        </span><span>// GitHub 全部域名</span></div></div><div><div><div>165</div></div><div><span>        </span><span>"type"</span><span>: </span><span>"remote"</span><span>,</span></div></div><div><div><div>166</div></div><div><span>        </span><span>"tag"</span><span>: </span><span>"github"</span><span>,</span></div></div><div><div><div>167</div></div><div><span>        </span><span>"format"</span><span>: </span><span>"binary"</span><span>,</span></div></div><div><div><div>168</div></div><div><span>        </span><span>"url"</span><span>: </span><span>"https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/sing/geo/geosite/github.srs"</span><span>,</span></div></div><div><div><div>169</div></div><div><span>        </span><span>"update_interval"</span><span>: </span><span>"7d"</span></div></div><div><div><div>170</div></div><div><span><span>      </span></span><span>},</span></div></div><div><div><div>171</div></div><div><span><span>      </span></span><span>{</span></div></div><div><div><div>172</div></div><div><span>        </span><span>// Google 全部域名，包括 Gemini</span></div></div><div><div><div>173</div></div><div><span>        </span><span>"type"</span><span>: </span><span>"remote"</span><span>,</span></div></div><div><div><div>174</div></div><div><span>        </span><span>"tag"</span><span>: </span><span>"google"</span><span>,</span></div></div><div><div><div>175</div></div><div><span>        </span><span>"format"</span><span>: </span><span>"binary"</span><span>,</span></div></div><div><div><div>176</div></div><div><span>        </span><span>"url"</span><span>: </span><span>"https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/sing/geo/geosite/google.srs"</span><span>,</span></div></div><div><div><div>177</div></div><div><span>        </span><span>"update_interval"</span><span>: </span><span>"7d"</span></div></div><div><div><div>178</div></div><div><span><span>      </span></span><span>},</span></div></div><div><div><div>179</div></div><div><span><span>      </span></span><span>{</span></div></div><div><div><div>180</div></div><div><span>        </span><span>// 下面四个是这几家服务自己的 IP 段，用来兜底「只有 IP、没有域名」的连接</span></div></div><div><div><div>181</div></div><div><span>        </span><span>"type"</span><span>: </span><span>"remote"</span><span>,</span></div></div><div><div><div>182</div></div><div><span>        </span><span>"tag"</span><span>: </span><span>"google-ip"</span><span>,</span></div></div><div><div><div>183</div></div><div><span>        </span><span>"format"</span><span>: </span><span>"binary"</span><span>,</span></div></div><div><div><div>184</div></div><div><span>        </span><span>"url"</span><span>: </span><span>"https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/sing/geo/geoip/google.srs"</span><span>,</span></div></div><div><div><div>185</div></div><div><span>        </span><span>"update_interval"</span><span>: </span><span>"7d"</span></div></div><div><div><div>186</div></div><div><span><span>      </span></span><span>},</span></div></div><div><div><div>187</div></div><div><span><span>      </span></span><span>{</span></div></div><div><div><div>188</div></div><div><span>        </span><span>"type"</span><span>: </span><span>"remote"</span><span>,</span></div></div><div><div><div>189</div></div><div><span>        </span><span>"tag"</span><span>: </span><span>"telegram-ip"</span><span>,</span></div></div><div><div><div>190</div></div><div><span>        </span><span>"format"</span><span>: </span><span>"binary"</span><span>,</span></div></div><div><div><div>191</div></div><div><span>        </span><span>"url"</span><span>: </span><span>"https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/sing/geo/geoip/telegram.srs"</span><span>,</span></div></div><div><div><div>192</div></div><div><span>        </span><span>"update_interval"</span><span>: </span><span>"7d"</span></div></div><div><div><div>193</div></div><div><span><span>      </span></span><span>},</span></div></div><div><div><div>194</div></div><div><span><span>      </span></span><span>{</span></div></div><div><div><div>195</div></div><div><span>        </span><span>"type"</span><span>: </span><span>"remote"</span><span>,</span></div></div><div><div><div>196</div></div><div><span>        </span><span>"tag"</span><span>: </span><span>"twitter-ip"</span><span>,</span></div></div><div><div><div>197</div></div><div><span>        </span><span>"format"</span><span>: </span><span>"binary"</span><span>,</span></div></div><div><div><div>198</div></div><div><span>        </span><span>"url"</span><span>: </span><span>"https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/sing/geo/geoip/twitter.srs"</span><span>,</span></div></div><div><div><div>199</div></div><div><span>        </span><span>"update_interval"</span><span>: </span><span>"7d"</span></div></div><div><div><div>200</div></div><div><span><span>      </span></span><span>},</span></div></div><div><div><div>201</div></div><div><span><span>      </span></span><span>{</span></div></div><div><div><div>202</div></div><div><span>        </span><span>"type"</span><span>: </span><span>"remote"</span><span>,</span></div></div><div><div><div>203</div></div><div><span>        </span><span>"tag"</span><span>: </span><span>"facebook-ip"</span><span>,</span></div></div><div><div><div>204</div></div><div><span>        </span><span>"format"</span><span>: </span><span>"binary"</span><span>,</span></div></div><div><div><div>205</div></div><div><span>        </span><span>"url"</span><span>: </span><span>"https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/sing/geo/geoip/facebook.srs"</span><span>,</span></div></div><div><div><div>206</div></div><div><span>        </span><span>"update_interval"</span><span>: </span><span>"7d"</span></div></div><div><div><div>207</div></div><div><span><span>      </span></span><span>},</span></div></div><div><div><div>208</div></div><div><span><span>      </span></span><span>{</span></div></div><div><div><div>209</div></div><div><span>        </span><span>// 国内 IP 段，用在最后一条 QUIC 规则里</span></div></div><div><div><div>210</div></div><div><span>        </span><span>"type"</span><span>: </span><span>"remote"</span><span>,</span></div></div><div><div><div>211</div></div><div><span>        </span><span>"tag"</span><span>: </span><span>"cn-ip"</span><span>,</span></div></div><div><div><div>212</div></div><div><span>        </span><span>"format"</span><span>: </span><span>"binary"</span><span>,</span></div></div><div><div><div>213</div></div><div><span>        </span><span>"url"</span><span>: </span><span>"https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/sing/geo/geoip/cn.srs"</span><span>,</span></div></div><div><div><div>214</div></div><div><span>        </span><span>"update_interval"</span><span>: </span><span>"7d"</span></div></div><div><div><div>215</div></div><div><span><span>      </span></span><span>}</span></div></div><div><div><div>216</div></div><div><span><span>    </span></span><span>],</span></div></div><div><div><div>217</div></div><div>
</div></div><div><div><div>218</div></div><div><span>    </span><span>// ---------- 分流规则：从上往下匹配，命中第一条就停 ----------</span></div></div><div><div><div>219</div></div><div><span>    </span><span>"rules"</span><span>: [</span></div></div><div><div><div>220</div></div><div><span><span>      </span></span><span>{</span></div></div><div><div><div>221</div></div><div><span>        </span><span>// 1. 嗅探：从 TLS、HTTP、QUIC 握手里读出域名</span></div></div><div><div><div>222</div></div><div><span>        </span><span>//    App 写死 IP、或浏览器自己用加密 DNS 时，靠它照样能按域名分流</span></div></div><div><div><div>223</div></div><div><span>        </span><span>"action"</span><span>: </span><span>"sniff"</span></div></div><div><div><div>224</div></div><div><span><span>      </span></span><span>},</span></div></div><div><div><div>225</div></div><div><span><span>      </span></span><span>{</span></div></div><div><div><div>226</div></div><div><span>        </span><span>// 2. 接管所有 DNS 查询，交给上面的 DNS 规则处理</span></div></div><div><div><div>227</div></div><div><span>        </span><span>"protocol"</span><span>: </span><span>"dns"</span><span>,</span></div></div><div><div><div>228</div></div><div><span>        </span><span>"action"</span><span>: </span><span>"hijack-dns"</span></div></div><div><div><div>229</div></div><div><span><span>      </span></span><span>},</span></div></div><div><div><div>230</div></div><div><span><span>      </span></span><span>{</span></div></div><div><div><div>231</div></div><div><span>        </span><span>// 3. 局域网地址走 Wi-Fi，不然路由器后台、打印机都访问不了</span></div></div><div><div><div>232</div></div><div><span>        </span><span>"ip_is_private"</span><span>: </span><span>true</span><span>,</span></div></div><div><div><div>233</div></div><div><span>        </span><span>"action"</span><span>: </span><span>"route"</span><span>,</span></div></div><div><div><div>234</div></div><div><span>        </span><span>"outbound"</span><span>: </span><span>"default"</span></div></div><div><div><div>235</div></div><div><span><span>      </span></span><span>},</span></div></div><div><div><div>236</div></div><div><span><span>      </span></span><span>{</span></div></div><div><div><div>237</div></div><div><span>        </span><span>// 4. Claude、ChatGPT、境外网站、GitHub、Google 走 eSIM</span></div></div><div><div><div>238</div></div><div><span>        </span><span>//    Steam 不用单独写：商店和社区在境外网站列表里，下载域名不在任何列表里，会走 Wi-Fi</span></div></div><div><div><div>239</div></div><div><span>        </span><span>"rule_set"</span><span>: [</span></div></div><div><div><div>240</div></div><div><span>          </span><span>"anthropic"</span><span>,</span></div></div><div><div><div>241</div></div><div><span>          </span><span>"openai"</span><span>,</span></div></div><div><div><div>242</div></div><div><span>          </span><span>"overseas-list"</span><span>,</span></div></div><div><div><div>243</div></div><div><span>          </span><span>"github"</span><span>,</span></div></div><div><div><div>244</div></div><div><span>          </span><span>"google"</span></div></div><div><div><div>245</div></div><div><span><span>        </span></span><span>],</span></div></div><div><div><div>246</div></div><div><span>        </span><span>"action"</span><span>: </span><span>"route"</span><span>,</span></div></div><div><div><div>247</div></div><div><span>        </span><span>"outbound"</span><span>: </span><span>"esim"</span></div></div><div><div><div>248</div></div><div><span><span>      </span></span><span>},</span></div></div><div><div><div>249</div></div><div><span><span>      </span></span><span>{</span></div></div><div><div><div>250</div></div><div><span>        </span><span>// 5. 只有 IP 的连接：按这几家服务的 IP 段兜底，也走 eSIM</span></div></div><div><div><div>251</div></div><div><span>        </span><span>"rule_set"</span><span>: [</span></div></div><div><div><div>252</div></div><div><span>          </span><span>"google-ip"</span><span>,</span></div></div><div><div><div>253</div></div><div><span>          </span><span>"telegram-ip"</span><span>,</span></div></div><div><div><div>254</div></div><div><span>          </span><span>"twitter-ip"</span><span>,</span></div></div><div><div><div>255</div></div><div><span>          </span><span>"facebook-ip"</span></div></div><div><div><div>256</div></div><div><span><span>        </span></span><span>],</span></div></div><div><div><div>257</div></div><div><span>        </span><span>"action"</span><span>: </span><span>"route"</span><span>,</span></div></div><div><div><div>258</div></div><div><span>        </span><span>"outbound"</span><span>: </span><span>"esim"</span></div></div><div><div><div>259</div></div><div><span><span>      </span></span><span>},</span></div></div><div><div><div>260</div></div><div><span><span>      </span></span><span>{</span></div></div><div><div><div>261</div></div><div><span>        </span><span>// 6. 发往境外 IP 的 QUIC（HTTP/3，UDP 443）一律拒绝</span></div></div><div><div><div>262</div></div><div><span>        </span><span>//    QUIC 有时读不出域名，会漏到 Wi-Fi；拒绝后 App 会立刻改用 TCP，</span></div></div><div><div><div>263</div></div><div><span>        </span><span>//    TCP 能读出域名，就能按上面的规则分流。国内 IP 的 QUIC 不受影响</span></div></div><div><div><div>264</div></div><div><span>        </span><span>"type"</span><span>: </span><span>"logical"</span><span>,</span></div></div><div><div><div>265</div></div><div><span>        </span><span>"mode"</span><span>: </span><span>"and"</span><span>,</span></div></div><div><div><div>266</div></div><div><span>        </span><span>"rules"</span><span>: [</span></div></div><div><div><div>267</div></div><div><span><span>          </span></span><span>{</span></div></div><div><div><div>268</div></div><div><span>            </span><span>"network"</span><span>: </span><span>"udp"</span><span>,</span></div></div><div><div><div>269</div></div><div><span>            </span><span>"port"</span><span>: </span><span>443</span></div></div><div><div><div>270</div></div><div><span><span>          </span></span><span>},</span></div></div><div><div><div>271</div></div><div><span><span>          </span></span><span>{</span></div></div><div><div><div>272</div></div><div><span>            </span><span>"rule_set"</span><span>: </span><span>"cn-ip"</span><span>,</span></div></div><div><div><div>273</div></div><div><span>            </span><span>"invert"</span><span>: </span><span>true</span></div></div><div><div><div>274</div></div><div><span><span>          </span></span><span>}</span></div></div><div><div><div>275</div></div><div><span><span>        </span></span><span>],</span></div></div><div><div><div>276</div></div><div><span>        </span><span>"action"</span><span>: </span><span>"reject"</span></div></div><div><div><div>277</div></div><div><span><span>      </span></span><span>}</span></div></div><div><div><div>278</div></div><div><span><span>    </span></span><span>],</span></div></div><div><div><div>279</div></div><div><span>    </span><span>// 7. 其他所有流量走 Wi-Fi</span></div></div><div><div><div>280</div></div><div><span>    </span><span>"final"</span><span>: </span><span>"default"</span></div></div><div><div><div>281</div></div><div><span><span>  </span></span><span>},</span></div></div><div><div><div>282</div></div><div>
</div></div><div><div><div>283</div></div><div><span>  </span><span>// ---------- 其他 ----------</span></div></div><div><div><div>284</div></div><div><span>  </span><span>"experimental"</span><span>: {</span></div></div><div><div><div>285</div></div><div><span>    </span><span>// 把规则集等缓存到本地，重启后不用重新下载</span></div></div><div><div><div>286</div></div><div><span>    </span><span>"cache_file"</span><span>: {</span></div></div><div><div><div>287</div></div><div><span>      </span><span>"enabled"</span><span>: </span><span>true</span></div></div><div><div><div>288</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>289</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>290</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><section><h3>配置解析<a href="#配置解析"><span>#</span></a></h3><p><strong>出口（<code>outbounds</code>）</strong></p><ul>
<li><code>default</code>：普通直连，跟随系统默认网络。</li>
<li><code>esim</code>：直连，用 <code>network_type</code> 限定只走蜂窝或有线网卡，原理见第四节。<code>network_strategy: default</code> 表示只用这两类网络，不会在它们不可用时偷偷改走 Wi-Fi。</li>
</ul><p><strong>DNS（<code>dns</code>）</strong></p><ul>
<li><code>dns-cn</code>：阿里的加密 DNS，走默认网络，解析国内网站。</li>
<li><code>dns-esim</code>：Cloudflare 的加密 DNS，经 <code>esim</code> 出口发出，专门解析要走 eSIM 的域名，保证拿到境外网络视角下可用的地址。</li>
<li>第一条规则让<strong>要走 eSIM 的域名只返回 IPv4 地址</strong>（拦掉 <code>AAAA</code> 查询）。境外 eSIM 漫游时只有 IPv4，拿到 IPv6 地址就连不上；国内网站不受影响，照常可以用 IPv6。为什么这条很重要，见第九节。</li>
</ul><p><strong>规则集（<code>route.rule_set</code>）</strong></p><p>全部来自 <a href="https://github.com/MetaCubeX/meta-rules-dat" target="_blank">MetaCubeX/meta-rules-dat</a> 项目的 <code>sing</code> 分支，配置里<strong>没有一条手写的域名</strong>：</p>

<table><thead><tr><th>配置里的名字</th><th>上游文件</th><th>内容</th></tr></thead><tbody><tr><td><code>anthropic</code></td><td><code>geo/geosite/anthropic.srs</code></td><td>Claude / Anthropic 的全部域名，包括 Artifact 用的 <code>claudeusercontent.com</code></td></tr><tr><td><code>openai</code></td><td><code>geo/geosite/openai.srs</code></td><td>ChatGPT / OpenAI 的全部域名，包括静态资源域名</td></tr><tr><td><code>overseas-list</code></td><td><code>geo/geosite/gfw.srs</code></td><td>常用境外网站域名（上游文件名沿用了项目里的原名），Steam 商店和社区、Telegram、X、YouTube、Grok 等都在里面</td></tr><tr><td><code>github</code></td><td><code>geo/geosite/github.srs</code></td><td>GitHub 全部域名</td></tr><tr><td><code>google</code></td><td><code>geo/geosite/google.srs</code></td><td>Google 全部域名，包括 Gemini</td></tr><tr><td><code>google-ip</code>、<code>telegram-ip</code>、<code>twitter-ip</code>、<code>facebook-ip</code></td><td><code>geo/geoip/</code> 下同名文件</td><td>这几家服务自己的 IP 段</td></tr><tr><td><code>cn-ip</code></td><td><code>geo/geoip/cn.srs</code></td><td>国内 IP 段</td></tr></tbody></table><p><code>http_clients</code> 定义了一个经 <code>esim</code> 出口下载的客户端，<code>route.default_http_client</code> 让所有规则集都用它下载，因为规则集托管在 GitHub 上。每 7 天更新一次（<code>update_interval: 7d</code>），列表变化很慢，没必要天天下载、浪费 eSIM 流量。</p><p><strong>分流规则（<code>route.rules</code>）</strong></p><p>规则<strong>从上往下匹配，命中第一条就停</strong>：</p><ol>
<li><code>sniff</code>：从 TLS、HTTP、QUIC 握手里读出域名。浏览器自己用加密 DNS 解析、或者 App 写死了 IP 时，靠它照样能按域名分流。</li>
<li><code>hijack-dns</code>：接管所有 DNS 查询，交给上面的 DNS 规则处理。</li>
<li>局域网地址走默认网络，不然路由器后台、打印机都访问不了。</li>
<li>Claude、ChatGPT、境外网站列表、GitHub、Google 走 <code>esim</code>。<strong>Steam 不用单独写</strong>：商店和社区的域名已经在境外网站列表里，下载用的 <code>steamcontent.com</code> 和国内下载节点不在任何列表里，会落到最后走 Wi-Fi。</li>
<li><strong>只有 IP 的连接按 IP 段兜底。</strong> Google、Telegram、Twitter（X）、Facebook 这几家有时会绕过域名、直接用 IP 连接，按它们自己的网段走 <code>esim</code>。</li>
<li><strong>发往境外 IP 的 QUIC 一律拒绝。</strong> 原因见第九节。</li>
<li>其他所有流量走 <code>default</code>（<code>final</code>）。</li>
</ol></section></section>
<section><h2>六、Mac 上的设置<a href="#六mac-上的设置"><span>#</span></a></h2><section><h3>安装<a href="#安装"><span>#</span></a></h3><p>sing-box 的 Mac 版在 App Store 里叫 sing-box，<a href="https://sing-box.sagernet.org/clients/apple/" target="_blank">官方文档</a>说需要中国大陆以外的 Apple 账户。我用外区账号也提示「所在国家尚不提供此 App」，最后用 Homebrew 装了官方的独立版（SFM），功能一样，也能读 iCloud 配置：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>brew</span><span> </span><span>install</span><span> </span><span>--cask</span><span> </span><span>sfm</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>注意是 <code>--cask sfm</code>。<code>brew install sing-box</code> 装的是命令行版，不支持 <code>network_type</code>，也读不了 iCloud 配置。</p><p>第一次启动时按提示允许安装网络扩展。如果系统提示「系统扩展已被阻止」，去「系统设置 → 隐私与安全性」点「允许」，之后再允许添加 VPN 配置。</p></section><section><h3>网络设置<a href="#网络设置"><span>#</span></a></h3><ol>
<li><strong>Wi-Fi 连家里路由器</strong>，并在「系统设置 → Wi-Fi」里关掉 iPhone 热点的「自动加入」，防止 Mac 自己连到热点上。</li>
<li><strong>iPhone 数据卡设成 eSIM</strong>，用数据线连 Mac，打开「个人热点 → 允许其他人加入」，第一次连接时在手机上点「信任」。</li>
<li><strong>关掉 iPhone USB 的「需要时启用」</strong>：「系统设置 → 网络 → iPhone USB → 详细信息」。</li>
<li><strong>服务顺序</strong>：Wi-Fi 排在 iPhone USB 上面。</li>
<li><strong>Wi-Fi 的 IPv6 设成「自动」</strong>：「系统设置 → 网络 → Wi-Fi → 详细信息 → TCP/IP」，「配置 IPv6」选「自动」。如果之前为了别的代理工具改成过「仅本地链接」，国内网站会全部打不开，原因见第九节。</li>
</ol></section><section><h3>导入配置<a href="#导入配置"><span>#</span></a></h3><p>在 sing-box 里新建配置，类型选「iCloud」，选中 <code>esim-split.json</code>，回到首页启动。</p><p>如果之前装过 Clash Verge Rev 之类的工具，先关掉它的虚拟网卡和系统代理并退出，两个软件同时接管流量会互相干扰。</p></section></section>
<section><h2>七、iPhone 上的设置<a href="#七iphone-上的设置"><span>#</span></a></h2><section><h3>装哪个 App<a href="#装哪个-app"><span>#</span></a></h3><p>sing-box 的 iOS 客户端在国区 App Store 搜不到，需要外区 Apple ID，App 本身免费。要装的是<a href="https://sing-box.sagernet.org/clients/apple/" target="_blank">官方文档里链接的那个</a>（App Store 上叫 sing-box MT）。</p><p>在 App Store 搜「sing-box」时排在前面的「sing-box VT」<strong>不是官方的</strong>：开发商是另一家公司，页面上自称「又一个发行版」，内核还停在 1.11。这份配置用的是 1.12 以后的 DNS 写法，在它上面会直接报错。</p></section><section><h3>设置步骤<a href="#设置步骤"><span>#</span></a></h3><ol>
<li><strong>导入配置</strong>：打开 sing-box →「配置」→ 新建 → 类型选「iCloud」→ 选 <code>esim-split.json</code>。回到「仪表」页面选中这个配置，打开开关。第一次会请求添加 VPN 配置，点允许。</li>
<li><strong>允许 sing-box 使用蜂窝数据</strong>：「设置 → 蜂窝网络」，往下翻到 sing-box，打开开关。不打开的话，<code>esim</code> 出口走不了蜂窝。</li>
<li><strong>在家时数据卡设成 eSIM</strong>：「设置 → 蜂窝网络 → 蜂窝数据」选 eSIM，并关掉同一页底部的「允许切换蜂窝数据」。</li>
<li><strong>关掉「无线局域网助理」</strong>：「设置 → 蜂窝网络」拉到最底部。它会在 Wi-Fi 信号差时自动改用蜂窝，在家时蜂窝就是 eSIM。</li>
<li><strong>给 eSIM 打开「低数据模式」</strong>：「设置 → 蜂窝网络 →」点 eSIM 线路。系统会暂停照片同步、自动更新这类后台任务，只影响蜂窝，连着 Wi-Fi 时照常进行。</li>
</ol><p>出门后把数据卡切回主号，这时两个出口都只剩蜂窝，sing-box 开着关着效果一样。</p></section><section><h3>连上 Wi-Fi 自动切卡、自动开 sing-box<a href="#连上-wi-fi-自动切卡自动开-sing-box"><span>#</span></a></h3><p>用「快捷指令」的自动化就能做到，连上 Wi-Fi 时自动把数据卡切到 eSIM，并打开 sing-box：</p><ol>
<li>「快捷指令」→「自动化」→「+」→「无线局域网」，网络选家里的 Wi-Fi，勾选「加入」，选「立即运行」。</li>
<li>添加操作「<strong>设定默认号码</strong>」，显示为「将默认 数据 号码设为 …」：把「数据」保持不变，号码选 eSIM。注意不要选成「语音」，那是打电话用的默认号码。</li>
<li>添加操作「<strong>设定 VPN</strong>」，显示为「连接 SFI」（SFI 是 sing-box 的 iOS 客户端在系统里的名字）。</li>
</ol><p>要在离开时自动关掉，再建一个自动化：同样选家里的 Wi-Fi，勾选「离开」，操作选「设定 VPN」→「断开连接」。想顺便切回主号，再加一个「设定默认号码」，号码选主号。</p><p>几个细节：</p><ul>
<li><strong>网络最好选家里的 Wi-Fi，而不是「任何网络」。</strong> 选「任何网络」的话，在外面连公司、咖啡店的 Wi-Fi 也会切到 eSIM。</li>
<li><strong>别同时打开 sing-box 自己的「按需启动」。</strong> 它的作用一般是「断了自动重连」，会和「离开时断开」打架。</li>
<li>网上很多文章说 iOS 14.5 起快捷指令就能切换数据卡，我核对后发现那次加的是「设定语音与数据」，只能切换 4G/5G 模式，不能换卡。实际能换卡的是「设定默认号码」这个操作，以你手机里实际能搜到的为准。</li>
</ul></section></section>
<section><h2>八、用 iCloud 同步配置<a href="#八用-icloud-同步配置"><span>#</span></a></h2><p>两台设备读的都是「iCloud 云盘 → sing-box」里的 <code>esim-split.json</code>。在 Mac 上用任意编辑器改完保存即可，但有两点要注意：</p><ol>
<li><strong>sing-box 不会自动重新加载。</strong> 它只在启动时读一次配置，改完后要在 App 里把开关关掉再打开。</li>
<li><strong>iPhone 要先收到新文件。</strong> iCloud 同步有时要等几秒到几分钟。想快一点，就在 iPhone 的「文件」App 里打开「iCloud 云盘 → sing-box」，点一下这个文件，它会立刻下载最新版，然后再重启 sing-box。</li>
</ol><p>改错了也不怕：配置有语法错误时，sing-box 启动会直接报错，不会带着错误配置悄悄运行。</p><p>另外，刚重启或者刚加了新规则集时，sing-box 要先经 eSIM 下载规则集，这几十秒里对应的网站可能打不开，等一会儿就好。</p></section>
<section><h2>九、一路补过的漏洞<a href="#九一路补过的漏洞"><span>#</span></a></h2><p>这套配置不是一次写成的。下面这些坑，有的是实际用的时候在日志和「连接」页面里发现的，有的是专门回头检查「会不会悄悄打不开」和「会不会悄悄用掉 eSIM 流量」时找到的。</p><section><h3>Mac 连接上的坑<a href="#mac-连接上的坑"><span>#</span></a></h3><p><strong>Wi-Fi 连的就是热点。</strong> 一开始 Mac 的 Wi-Fi 也连着 iPhone 热点，所有流量都在走热点，没有东西可分。记得在「系统设置 → Wi-Fi」里把 iPhone 热点的「自动加入」关掉，不然 Mac 会自己连回去。</p><p><strong>iPhone USB 显示「已连接」，却拿不到 IP。</strong> 用 <code>ifconfig en6</code> 一看，状态是 <code>inactive</code>。原因是「系统设置 → 网络 → iPhone USB → 详细信息」里的<strong>「需要时启用」</strong>开着（英文版叫 Disable unless needed）：只要还有 Wi-Fi 可用，系统就把 iPhone USB 停掉，左边的状态点是黄色的。关掉之后状态点变绿，马上拿到了 <code>172.20.10.x</code>。这是整个过程里最隐蔽的一个坑。</p><p><strong>iPhone 开热点后自己断开了 Wi-Fi。</strong> iPhone 打开个人热点后，通常会断开它自己连着的 Wi-Fi，因为热点要占用 Wi-Fi 芯片。这时 iPhone 上所有 App 都改走蜂窝数据，而数据卡正是 eSIM，微信、视频这些国内流量也会消耗 eSIM。所以 Mac 用热点时手机尽量少用，用完就关热点，iPhone 会自动连回 Wi-Fi。</p></section><section><h3>规则上的漏洞<a href="#规则上的漏洞"><span>#</span></a></h3><p><strong>Claude 的 Artifact 有时打不开。</strong> 最初 Claude 的规则是手写的 <code>claude.ai</code>、<code>claude.com</code>、<code>anthropic.com</code> 三个域名，而 Artifact（Claude 生成的网页）运行在独立的域名 <code>claudeusercontent.com</code> 下。查了社区项目 <a href="https://github.com/v2fly/domain-list-community" target="_blank">v2fly/domain-list-community</a> 的 <code>data/anthropic</code>，里面已经收录了 Anthropic 的十几个域名，于是改用这个社区列表，以后 Claude 启用新域名也会自动跟进。ChatGPT 同理，境外网站列表里只有主域名，没有 <code>oaistatic.com</code> 这类静态资源域名，加了社区的 <code>openai</code> 列表。</p><p><strong>Steam 的规则写了一大串，其实一条都不需要。</strong> Clash 时期我写了十几条 Steam 规则：先把下载、客户端更新、国内下载节点挑出来走 Wi-Fi，再让商店走热点。之所以这么麻烦，是因为用了 <code>steampowered.com</code> 这种宽泛的写法，会把 <code>client-download.steampowered.com</code> 这类下载域名也带去走热点。后来对照了境外网站列表，它已经精确收录了商店和社区的子域名（<code>store.steampowered.com</code>、<code>steamcommunity.com</code>、<code>steamstatic.com</code> 等），下载域名一个都不在里面。什么都不写，结果就是「商店走 eSIM、下载走 Wi-Fi」，和游戏加速器的「商店加速」是一个效果。唯一的代价是商店里的预告片视频也会走 eSIM。</p><p><strong>QUIC 连接识别不出域名。</strong> iPhone 的连接页面里有一条 <code>UDP 64.233.170.101:443</code> 走了 Wi-Fi，下载量为 0，30 秒后超时。这是 Google 的 IP，用的是 QUIC（HTTP/3）。这种新协议的域名有时读不出来，连接只剩一个 IP，所有域名规则都失效，落到了默认出口。App 发现 QUIC 连不上，一般会退回普通的 TCP，所以最终能用，只是会慢几秒。</p><p>与其追着整理 IP 段，不如<strong>从根上把这类连接变成能识别域名的连接</strong>：发往境外 IP 的 QUIC 一律拒绝。App 会立刻改用 TCP，这是所有浏览器和主流 App 都有的标准行为，几乎没有延迟；TCP 连接能读出域名，自然就按列表分流了。国内 IP 的 QUIC 不受影响。另外再按 Google、Telegram、Twitter、Facebook 这几家自己的 IP 段兜底，覆盖那些直接用 IP 连接的情况。</p><p><strong>换成 sing-box 后，Mac 上国内网站全部打不开。</strong> Google 这类境外网站正常，国内网站在浏览器里一个都打不开，可 <code>nslookup baidu.com</code> 能解析，<code>curl https://www.baidu.com</code> 也返回 200。看 sing-box 日志，满屏都是这样的报错：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>connection: open connection to [240e:83:205:381:0:ff:b00f:96a2]:443</span></div></div><div><div><div>2</div></div><div><span>using outbound/direct[default]: dial en0 (8): dial tcp [...]: connect: no route to host</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>240e:</code> 开头的是国内运营商的 IPv6 地址。国内大网站基本都有 IPv6 地址，浏览器优先用 IPv6 去连，而 Mac 的 Wi-Fi（<code>en0</code>）上根本没有 IPv6 线路，连不上，浏览器也没有退回 IPv4；<code>curl</code> 失败后会马上换 IPv4，所以它能成功。</p><p>我一度以为是家里宽带没有 IPv6，在配置里把 IPv6 全部禁掉（DNS 只返回 IPv4 地址、拒绝所有 IPv6 连接），国内网站确实恢复了。但同一个 Wi-Fi 下 iPhone 走 IPv6 完全正常，说明宽带是有 IPv6 的。最后查到原因在 Mac 自己：用 Clash 时，为了防止少数 App 走 IPv6 绕过 Clash，我把 Mac Wi-Fi 的 IPv6 设成了「仅本地链接」，换成 sing-box 后忘了改回来。</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>networksetup</span><span> </span><span>-getinfo</span><span> </span><span>Wi-Fi</span><span> | </span><span>grep</span><span> </span><span>-i</span><span> </span><span>ipv6</span></div></div><div><div><div>2</div></div><div><span>networksetup</span><span> </span><span>-setv6automatic</span><span> </span><span>Wi-Fi</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>第一条显示 <code>IPv6: Link-Local Only</code> 就是这个问题，第二条把它改回「自动」（也可以在「系统设置 → 网络 → Wi-Fi → 详细信息 → TCP/IP」里把「配置 IPv6」改成「自动」）。改回之后国内网站立刻恢复，配置里那两处全局的 IPv6 限制也就去掉了。sing-box 的虚拟网卡本身会接管 IPv6 流量，不存在 Clash 时期担心的「走 IPv6 绕过分流」。</p><p><strong>去掉 IPv6 限制后，Claude 桌面版和电脑断开了连接。</strong> 全局限制去掉之后，国内网站正常了，在 Claude 网页上聊天也正常，但 Claude 桌面版连接这台电脑的功能（让 Claude 读写电脑上的文件）一直显示断开，重启桌面版也没用。</p><p>原因还是 IPv6，只不过这次出在 eSIM 那一侧：限制全部去掉后，Claude 的域名在 DNS 里也会返回 IPv6 地址，而这些连接走 <code>esim</code> 出口，eSIM 漫游只有 IPv4，IPv6 连不通。网页聊天遇到 IPv6 连不上会自己退回 IPv4，所以看不出问题；桌面版那条长连接就没那么「聪明」，卡在 IPv6 上一直连不起来。</p><p>修法是把 IPv6 限制收窄到真正需要的地方：<strong>只对要走 eSIM 的域名不返回 IPv6 地址</strong>，也就是配置里 DNS 规则的第一条。改完重启 sing-box 和桌面版，连接马上恢复。这其实就是 iPhone 最早那版配置的写法，中间为了解决国内网站的问题把它扩大成了全局限制，再全部去掉时又把这条必要的也一起删了。</p><p>总结一下 IPv6 的处理原则：<strong>哪条线路没有 IPv6，就只对走那条线路的域名拦掉 IPv6</strong>。家里 Wi-Fi 有 IPv6，国内网站就放开；eSIM 没有，走 eSIM 的域名就只给 IPv4。</p><p>Chrome 的「安全 DNS」也会绕过系统 DNS。它默认是「自动」模式，只有系统 DNS 正好是 Google、Cloudflare 这类知名服务商时才会升级成加密 DNS；系统 DNS 是家里路由器时不会触发。所以只要没手动选过某个服务商，保持默认就行，可以在 Chrome 地址栏输入 <code>chrome://settings/security</code> 检查。就算触发了，分流规则里的 <code>sniff</code> 也能从握手里读出域名。</p><p><strong>查不出是哪个 App 发的连接。</strong> 日志里还有几个只有 IP、走 Wi-Fi、5 秒超时的连接，想知道是哪个 App 发的。sing-box 有个 <code>find_process</code> 选项能记录进程名，但 Mac 上的独立版运行在系统沙盒里，日志只给出 <code>failed to search process: Couldn’t communicate with a helper application</code>，查不了。这种情况可以用系统自带的 <code>lsof</code> 查，比如：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>sudo</span><span> </span><span>lsof</span><span> </span><span>-nP</span><span> </span><span>-i</span><span> </span><span>@173.255.213.90</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>偷跑 eSIM 流量的漏洞<a href="#偷跑-esim-流量的漏洞"><span>#</span></a></h3><p><strong>iPhone 上，国内网站可能被 eSIM 抢先连上。</strong> 一开始 iPhone 的 Wi-Fi 出口设的是「Wi-Fi 优先，失败换蜂窝」（<code>network_strategy: fallback</code>），想着出门时开着 sing-box 也不会断网。后来回头细看官方文档才发现，这个模式的行为是：Wi-Fi 上的连接只要 <strong>300 毫秒内没建立好</strong>，就会<strong>同时</strong>用蜂窝再发起一个连接，谁先连上用谁。国内稍慢一点的服务器，都可能被 eSIM 抢先连上，完全察觉不到。</p><p>改成普通直连、跟随系统默认网络之后就没这个问题了：在家走 Wi-Fi，出门走蜂窝，不会同时发起两个连接。</p><p><strong>Mac 上，热点探测太频繁。</strong> 这是 Clash 时期的问题：「热点优先」组每次探测都要经热点建立一次加密连接，我一度把间隔设成 30 秒，一天两千多次，一个月可能要几百 MB 热点流量。sing-box 方案没有探测，也就没有这个问题。</p><p><strong>规则集天天下载。</strong> 规则列表加起来有几 MB，原来每天更新，一个月也有几十 MB 走 eSIM。这些列表变化很慢，现在都改成每 7 天更新一次。</p><p><strong>iPhone 的「无线局域网助理」和后台任务。</strong> 前者在 Wi-Fi 信号差时会自动改用蜂窝，后者（照片同步、自动更新）在没有 Wi-Fi 时会用蜂窝。前者关掉，后者靠 eSIM 的「低数据模式」压住，见第七节。</p></section><section><h3>sing-box 的弃用警告<a href="#sing-box-的弃用警告"><span>#</span></a></h3><p>第一次导入时弹出「弃用警告」：远程规则集的 <code>download_detour</code> 写法在 1.14 已被弃用，1.16 会移除。这不是报错，配置照常能运行。既然装的已经是 1.14 以上，就改成了新写法：定义一个经 <code>esim</code> 出口下载的 HTTP 客户端（<code>http_clients</code>），在 <code>route.default_http_client</code> 里设为默认。</p></section></section>
<section><h2>十、省流量：这些服务也会走 eSIM<a href="#十省流量这些服务也会走-esim"><span>#</span></a></h2><p>按规则走 eSIM 的网站里，有几类特别费流量，要心里有数：</p>

<table><thead><tr><th>服务</th><th>为什么费流量</th><th>建议</th></tr></thead><tbody><tr><td>YouTube</td><td>在 Google 列表里，视频全走 eSIM</td><td>少看，或调低画质</td></tr><tr><td>Homebrew 安装软件</td><td>很多安装包和源码从 GitHub 下载</td><td>配国内镜像；Intel + Sonoma 的 Mac 没有编译好的安装包，要从各软件官网下载源码编译，镜像只能加速一部分</td></tr><tr><td>Docker 拉镜像</td><td>Docker Hub 在境外网站列表里</td><td>配置国内镜像源</td></tr><tr><td>Hugging Face 下载模型</td><td>在境外网站列表里</td><td>模型文件动辄几个 GB，尽量别在 eSIM 上下载</td></tr><tr><td>Chrome 自动更新</td><td>从 Google 服务器下载</td><td>留意一下就行，一次一两百 MB</td></tr></tbody></table><p>npm 用的是国内镜像（<code>registry.npmmirror.com</code>），不受影响。</p></section>
<section><h2>十一、怎么确认分流生效了<a href="#十一怎么确认分流生效了"><span>#</span></a></h2><p><strong>看 sing-box 的「连接」页面。</strong> Mac 和 iPhone 上都有。打开几个网站：</p>

<table><thead><tr><th>网站</th><th>出口应该显示</th></tr></thead><tbody><tr><td>google.com、github.com、claude.ai、chatgpt.com</td><td><code>esim</code></td></tr><tr><td>baidu.com、bilibili.com</td><td><code>default</code></td></tr><tr><td>Steam 下载游戏</td><td><code>default</code></td></tr></tbody></table><p><strong>看日志。</strong> Mac 上走 eSIM 的连接会显示 <code>outbound/direct[esim]: dial en6</code> 这样的记录，<code>en6</code> 是 iPhone 热点网卡，说明 <code>ethernet</code> 那一招生效了。</p><p><strong>留意这种连接：</strong> 只显示 IP、走的是 <code>default</code>、5 秒左右超时。这基本就是一个本该走 eSIM 却没被规则认出来的连接，可以用第九节的 <code>lsof</code> 查是哪个 App。</p><p><strong>看 eSIM 用了多少流量。</strong> 最准的是 iPhone 上的「设置 → 蜂窝网络」：eSIM 线路的总用量里，Mac 用掉的部分单独记在「个人热点」那一项。可以每月初点一次最底下的「重置统计数据」。Mac 上也能看这次插线以来的热点用量：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>netstat</span><span> </span><span>-ib</span><span> </span><span>-I</span><span> </span><span>en6</span><span> | </span><span>awk</span><span> </span><span>'NR==2{printf "下载 %.1f MB，上传 %.1f MB\n", $7/1048576, $10/1048576}'</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>把 <code>en6</code> 换成你的热点网卡名。</p></section>
<section><h2>十二、规则从哪来，以后怎么加<a href="#十二规则从哪来以后怎么加"><span>#</span></a></h2><section><h3>社区规则的来源<a href="#社区规则的来源"><span>#</span></a></h3><p>现在的规则全部来自两个开源项目：</p><ul>
<li><a href="https://github.com/v2fly/domain-list-community" target="_blank">v2fly/domain-list-community</a>：<strong>源头</strong>。社区手工维护的「服务 → 域名」清单，<code>data</code> 目录里一个文件就是一个列表，纯文本，网页上就能看，比如 <code>data/anthropic</code>、<code>data/openai</code>、<code>data/xai</code>。</li>
<li><a href="https://github.com/MetaCubeX/meta-rules-dat" target="_blank">MetaCubeX/meta-rules-dat</a>：每天自动从上面编译成规则集。<code>sing</code> 分支给 sing-box（<code>.srs</code>），<code>meta</code> 分支给 Clash（<code>.mrs</code>）；<code>meta</code> 分支里还有同名的 <code>.list</code> 文本版，想看内容可以直接打开。</li>
</ul><p>列表分两种：<code>geosite</code> 按<strong>域名</strong>分组，是主力；<code>geoip</code> 按 <strong>IP 段</strong>分组，只用来兜底没有域名的连接。</p></section><section><h3>加一个服务<a href="#加一个服务"><span>#</span></a></h3><p>遇到打不开的网站，先在 v2fly 的 <code>data</code> 目录里找有没有现成的列表。有的话，在 <code>route.rule_set</code> 里加一个规则集，订阅地址把文件名换成列表名：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/sing/geo/geosite/列表名.srs</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>再把它的 <code>tag</code> 加进 DNS 规则和分流规则里那两个 <code>["anthropic", "openai", ...]</code> 列表。</p><p>几个我看过、但<strong>没有加</strong>的列表：</p>

<table><thead><tr><th>列表</th><th>为什么没加</th></tr></thead><tbody><tr><td><code>category-ai-!cn</code></td><td>境外 AI 服务合集，但里面有 Hugging Face、Ollama 这类下载大模型的网站，全走 eSIM 很费流量</td></tr><tr><td><code>geolocation-!cn</code></td><td>「所有境外网站」，大量本来能直连的流量也会走 eSIM</td></tr><tr><td><code>xai</code>、<code>telegram</code>、<code>twitter</code> 等</td><td>主要域名已经在境外网站列表里，加了只是重复</td></tr></tbody></table><p>如果是社区列表漏了某个域名，可以去 v2fly 提 Issue 或 PR，合并后所有人都能用上。</p></section><section><h3>临时加一个域名<a href="#临时加一个域名"><span>#</span></a></h3><p>社区列表还没收录时，可以在配置里临时加两处：</p><ol>
<li>
<p><code>dns.rules</code> 里 <code>"server": "dns-esim"</code> 那条前面，加一条让它用境外 DNS 解析：</p>
<div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>{ </span><span>"domain_suffix"</span><span>: [</span><span>"example.com"</span><span>], </span><span>"action"</span><span>: </span><span>"route"</span><span>, </span><span>"server"</span><span>: </span><span>"dns-esim"</span><span> },</span></div></div></code></pre><div><div></div><div></div></div></figure></div>
</li>
<li>
<p><code>route.rules</code> 里第一条 <code>"outbound": "esim"</code> 前面，加一条让它走 eSIM：</p>
<div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>{ </span><span>"domain_suffix"</span><span>: [</span><span>"example.com"</span><span>], </span><span>"action"</span><span>: </span><span>"route"</span><span>, </span><span>"outbound"</span><span>: </span><span>"esim"</span><span> },</span></div></div></code></pre><div><div></div><div></div></div></figure></div>
</li>
</ol><p>改的是 iCloud 里的那个文件，两台设备都会生效，记得重启 sing-box。</p></section><section><h3>不知道该加哪个域名时<a href="#不知道该加哪个域名时"><span>#</span></a></h3><p>打开那个网站或应用，再到 sing-box 的「连接」页面，看它实际访问了哪些域名、走的是哪个出口，把走错的那个加进去即可。</p></section></section>
<section><h2>十三、这套配置能多久不用动<a href="#十三这套配置能多久不用动"><span>#</span></a></h2>

<table><thead><tr><th>什么情况要改</th><th>频率</th><th>怎么处理</th></tr></thead><tbody><tr><td>规则集更新</td><td>每周</td><td>自动，不用管</td></tr><tr><td>遇到列表里没有的网站</td><td>很少</td><td>先查社区列表，没有再按第十二节临时加</td></tr><tr><td>换了手机或电脑</td><td>很少</td><td>不用改配置，网卡名不写在配置里</td></tr><tr><td>软件大版本升级改了配置格式</td><td>一年一两次</td><td>出现弃用警告时再改</td></tr></tbody></table><p>最后一种最可能让配置失效，sing-box 几乎每个大版本都会淘汰一些旧写法。所以我关掉了 App Store 的自动更新（「设置 → App Store → 自动下载」），Mac 上的 Homebrew 版也不会自动更新，升级时机自己掌握。按现在的写法，至少要到 sing-box 1.16 才会有必须改的地方。</p></section>
<section><h2>十四、新设备快速配置清单<a href="#十四新设备快速配置清单"><span>#</span></a></h2><section><h3>Mac<a href="#mac"><span>#</span></a></h3><ol>
<li><strong>装 sing-box</strong>：<code>brew install --cask sfm</code>，第一次启动时允许网络扩展和 VPN 配置。</li>
<li><strong>连好网络</strong>：
<ul>
<li>Wi-Fi 连家里路由器，关掉 iPhone 热点的「自动加入」</li>
<li>iPhone 数据卡设成 eSIM，用数据线连电脑，打开「个人热点 → 允许其他人加入」，第一次连接时在手机上点「信任」</li>
<li>「系统设置 → 网络 → iPhone USB → 详细信息」，关掉「需要时启用」</li>
<li>「设定服务顺序」里 Wi-Fi 排在 iPhone USB 上面</li>
<li>Wi-Fi 的「配置 IPv6」是「自动」，不是「仅本地链接」</li>
</ul>
</li>
<li><strong>导入配置</strong>：把第五节的配置保存为「iCloud 云盘 → sing-box」里的 <code>esim-split.json</code>，在 sing-box 里新建 iCloud 配置并启动。</li>
<li><strong>验证</strong>：打开 google.com 和 baidu.com，在「连接」页面看到 <code>esim</code> 和 <code>default</code>。</li>
</ol></section><section><h3>iPhone<a href="#iphone"><span>#</span></a></h3><ol>
<li><strong>装 sing-box</strong>：用外区 Apple ID 安装官方文档链接的 sing-box MT。</li>
<li><strong>导入配置</strong>：新建 iCloud 配置，选同一个 <code>esim-split.json</code>，打开开关。</li>
<li><strong>系统设置</strong>：允许 sing-box 使用蜂窝数据；在家时数据卡设成 eSIM，关掉「允许切换蜂窝数据」；关掉「无线局域网助理」；给 eSIM 打开「低数据模式」。</li>
<li><strong>自动化</strong>：按第七节建「连上家里 Wi-Fi 时切到 eSIM 并连接 sing-box」的快捷指令自动化。</li>
<li><strong>验证</strong>：连着家里 Wi-Fi，分别打开 google.com 和 baidu.com，在「连接」页面看到 <code>esim</code> 和 <code>default</code>。</li>
</ol><blockquote><p>参考：<a href="https://sing-box.sagernet.org/" target="_blank">sing-box 文档</a>、<a href="https://sing-box.sagernet.org/configuration/shared/dial/" target="_blank">sing-box 拨号字段（network_type）</a>、<a href="https://sing-box.sagernet.org/zh/clients/apple/" target="_blank">sing-box 苹果客户端</a>、<a href="https://formulae.brew.sh/cask/sfm" target="_blank">Homebrew：sfm</a>、<a href="https://github.com/v2fly/domain-list-community" target="_blank">v2fly/domain-list-community</a>、<a href="https://github.com/MetaCubeX/meta-rules-dat" target="_blank">MetaCubeX/meta-rules-dat</a>、<a href="https://wiki.metacubex.one/" target="_blank">mihomo 文档</a>、<a href="https://support.apple.com/109317" target="_blank">苹果：配合 eSIM 使用双卡</a>。</p></blockquote></section></section>]]></content>
    </entry>
    <entry>
      <id>https://tracerzzz.cn/posts/oclp-sonoma-2016-macbook-pro/</id>
      <title type="text">给 2016 款 MacBook Pro 装上 macOS Sonoma：OpenCore Legacy Patcher 实录</title>
      <published>2026-10-26T17:30:00.000Z</published>
      <updated>2026-10-27T15:30:00.000Z</updated>
      <author><name>tracerZzz</name></author>
      <link rel="alternate" href="https://tracerzzz.cn/posts/oclp-sonoma-2016-macbook-pro/"/>
      <summary type="text">家里有一台 2016 款 15 英寸 MacBook Pro，官方最高只能升到 macOS 12，装不了要求 macOS 13 以上的新软件。用 OpenCore Legacy Patcher 把它升到了 Sonoma 14，这篇记录选版本的考虑、安装步骤，以及装完后遇到的屏幕闪烁、Wi-Fi 消失、指纹失效、iCloud 退不出去、睡眠唤醒死机这些坑。</summary>
      <content type="html"><![CDATA[<blockquote><p>2026-10-27 晚更新：用了一天后发现一睡眠就再也唤醒不了，补上了<a href="#%E7%9D%A1%E7%9C%A0%E5%90%8E%E5%94%A4%E9%86%92%E5%B0%B1%E6%AD%BB%E6%9C%BA">睡眠唤醒死机</a>的排查过程和最终设置。</p></blockquote>
<p>家里有一台 2016 款 15 英寸 MacBook Pro（i7 四核、16GB 内存），一直停在 macOS 12 Monterey。这是苹果给它的最后一个版本，“软件更新”里不会再出现新系统。</p>
<p>平时用着没什么问题，直到想装一个桌面应用，发现它要求 macOS 13 以上。越来越多的新软件都是这个要求，这台机器眼看就要被软件淘汰了。于是用 <strong>OpenCore Legacy Patcher</strong>（简称 OCLP）把它升到了 macOS 14 Sonoma。</p>
<p>这篇记录整个过程，包括中间踩到的坑。</p>
<section><h2>一、OCLP 是什么<a href="#一oclp-是什么"><span>#</span></a></h2><p>OCLP 是社区维护的开源工具，专门让苹果已经不支持的老 Mac 装上新系统。它做两件事：</p><ul>
<li><strong>开机引导</strong>：在硬盘的 EFI 分区装一个 OpenCore 引导器。每次开机先经过它，它让新系统”认为”这台机器是受支持的型号。</li>
<li><strong>补驱动</strong>：新系统删掉了老硬件的驱动（显卡、无线网卡等），OCLP 装完系统后再把这些驱动补回去，叫 <strong>Root Patch</strong>。</li>
</ul><p>这台 2016 款 15 英寸（型号 <code>MacBookPro13,3</code>）在 OCLP 的支持列表里。</p></section>
<section><h2>二、选哪个版本<a href="#二选哪个版本"><span>#</span></a></h2><p><strong>OCLP 版本：2.5.1。</strong>下载时 Releases 页面最新的已经是 3.x，但都标着 Pre-release（预发布）。3.x 主要是为了支持 macOS 26 Tahoe，还在完善中，所以选了 2.x 里最新的正式版 2.5.1。</p><p>注意：从 2.3.0 开始，官方不再提供 <code>OpenCore-Patcher-GUI.app.zip</code>，要下载的是 <strong><code>OpenCore-Patcher.pkg</code></strong>，双击安装后在”应用程序”里打开。</p><p><strong>macOS 版本：14 Sonoma。</strong></p>

<table><thead><tr><th>版本</th><th>考虑</th></tr></thead><tbody><tr><td>13 Ventura</td><td>能满足”13 以上”的要求，但没有理由不再往上一版</td></tr><tr><td><strong>14 Sonoma</strong></td><td>在老机型上用 OCLP 安装的人多，补丁成熟，选它</td></tr><tr><td>15 Sequoia</td><td>OCLP 2.x 的发布说明里一直写着”支持仍在积极开发中”；它的亮点功能（iPhone 镜像、Apple 智能）在 Intel 机器上本来就用不了</td></tr><tr><td>26 Tahoe</td><td>只有 3.x 预发布版支持，而且是最后一个支持 Intel 的系统，不折腾</td></tr></tbody></table></section>
<section><h2>三、安装前要想清楚的两件事<a href="#三安装前要想清楚的两件事"><span>#</span></a></h2><section><h3>要不要备份<a href="#要不要备份"><span>#</span></a></h3><p>稳妥的做法是先用时间机器备份一次。备份防两种意外：安装中途失败系统起不来；装完不满意想退回去。后一种最麻烦，从新系统退回旧系统必须抹盘重装。</p><p>我这台机器上没有什么要紧的东西，就没有备份。如果你的电脑里有重要文件，一定先备份。</p></section><section><h3>要不要抹盘重装<a href="#要不要抹盘重装"><span>#</span></a></h3><p><strong>不要抹盘</strong>，直接在原系统上升级。原因在 T1 芯片。</p><p>2016、2017 款带 Touch Bar 的 MacBook Pro 都有一颗 <strong>T1 芯片</strong>，它是苹果的第一代安全芯片，相当于电脑里的一台小电脑，负责 Touch Bar、Touch ID 指纹、Apple Pay。</p><p>Sonoma 删掉了对 T1 的支持。OCLP 1.1.0 以后能补回来，但前提是硬盘上还留着旧系统里和 T1 配合用的文件。OCLP 官方文档写明：<strong>抹掉硬盘会清除 T1 的固件，并且不会重新安装。</strong>所以直接升级更稳。</p><p>想要一台干净的系统，正确顺序是：先在恢复模式里抹盘、重装一个全新的 Monterey，再用 OCLP 升级到 Sonoma。</p></section></section>
<section><h2>四、安装步骤<a href="#四安装步骤"><span>#</span></a></h2><p>需要准备一块 16GB 以上的 U 盘或移动硬盘。我用的是一块 1TB 的希捷移动硬盘，原来是 NTFS 格式，Mac 上只能读不能写。这不影响做安装盘：OCLP 写入时会把整块盘抹掉，重新分区成 macOS 的格式，根本不会往 NTFS 里写东西。</p><ol>
<li><strong>安装 OCLP</strong>：下载 <code>OpenCore-Patcher.pkg</code>，双击安装，打开 OpenCore-Patcher。</li>
<li><strong>做安装盘</strong>：点 <strong>Create macOS Installer → Download macOS Installer</strong>，选 Sonoma 14.x 的最新版，下载约 13GB，然后写入移动硬盘。选盘时看清容量，别选成内置硬盘。
<ul>
<li>安装器走的是苹果官方的软件更新服务器，国内下载速度还可以；</li>
<li>写入时进度条会在某个百分比停很久，这是正常的。可以打开”活动监视器”的”磁盘”标签页，看写入的数据还在不在涨；</li>
<li>已经有”安装 macOS Sonoma.app”的话，可以选 <strong>Use Existing macOS Installer</strong>，省掉下载。</li>
</ul>
</li>
<li><strong>把 OpenCore 装到内置硬盘</strong>：点 <strong>Build and Install OpenCore → Install to disk</strong>，选内置硬盘的 EFI 分区。装在内置硬盘上，以后开机就自动经过 OpenCore，不用插着 U 盘。</li>
<li><strong>从安装盘启动</strong>：插着移动硬盘重启，按住 <strong>Option</strong> 键，选 <strong>EFI Boot</strong>，再在 OpenCore 的菜单里选 <strong>Install macOS Sonoma</strong>。</li>
<li><strong>安装</strong>：目标选 <strong>Macintosh HD</strong>，<strong>不要进磁盘工具抹盘</strong>，直接安装就是保留数据的升级。中途会自动重启好几次，黑屏、白苹果卡很久都正常，全程约一小时。</li>
<li><strong>打补丁</strong>：进入系统后打开 OCLP，点 <strong>Post-Install Root Patch → Start Root Patching</strong>，完成后重启。</li>
</ol></section>
<section><h2>五、踩到的坑<a href="#五踩到的坑"><span>#</span></a></h2><section><h3>选了 EFI Boot，还没来得及选就直接进了旧系统<a href="#选了-efi-boot还没来得及选就直接进了旧系统"><span>#</span></a></h3><p>OpenCore 的选择菜单默认隐藏或者倒计时很短，选了 EFI Boot 之后，它自动启动了默认系统，也就是原来的 Monterey。</p><p>解决：选 EFI Boot 回车后，<strong>立刻按住 Option 键（或 Esc）</strong>，直到出现 OpenCore 的菜单。也可以在 OCLP 的 <strong>Settings → Build</strong> 里打开 <strong>Show OpenCore Bootpicker</strong>，重新 Build and Install OpenCore，以后每次开机都会显示菜单。</p></section><section><h3>装完屏幕一直闪，设置向导里搜不到 Wi-Fi<a href="#装完屏幕一直闪设置向导里搜不到-wi-fi"><span>#</span></a></h3><p>这两个是同一个原因：Sonoma 里没有这台机器集成显卡和无线网卡的驱动，要靠 Root Patch 补回来。</p><ul>
<li><strong>屏幕闪</strong>：Intel 集成显卡没有驱动。可以先在”系统设置 → 电池 → 选项”里关掉”自动切换显卡”，让系统固定用 AMD 独立显卡（它的驱动 Sonoma 自带），屏幕就不闪了。</li>
<li><strong>没有 Wi-Fi</strong>：设置向导里点”其他网络选项 → 我的电脑未连接到互联网”跳过，Apple ID 也选”稍后设置”，先进桌面。</li>
</ul><p>进桌面后打补丁，这台机器需要的补丁是：</p>

<table><thead><tr><th>补丁</th><th>作用</th></tr></thead><tbody><tr><td>Graphics: Intel Skylake</td><td>Intel HD 530 集成显卡驱动</td></tr><tr><td>Graphics: AMD Polaris</td><td>AMD Radeon Pro 独立显卡相关的补丁</td></tr><tr><td>Networking: Modern Wireless</td><td>博通无线网卡驱动</td></tr><tr><td>Miscellaneous: T1 Security Chip</td><td>T1 芯片支持</td></tr></tbody></table><p>补丁文件已经打包在 OCLP 的安装包里，我在没有联网的情况下打补丁也成功了。打完重启，屏幕不闪了，Wi-Fi 也回来了。日志里会提到 KDK（内核开发包），部分情况下要从 GitHub 下载它，如果打补丁时提示下载失败，可以用 iPhone 的个人热点通过数据线给 Mac 临时联网。</p></section><section><h3>Touch ID 录不进指纹<a href="#touch-id-录不进指纹"><span>#</span></a></h3><p>升级后 Touch Bar 正常，说明 T1 芯片在工作，补丁也生效了；但在”触控 ID 与密码”里添加指纹，输完密码没有反应。</p><p>试过的办法：</p><ul>
<li>完整关机再开机，让 T1 重新初始化；</li>
<li>在 OCLP 里 <strong>Revert Root Patches</strong> 撤销补丁、重启，再重新打一遍补丁。</li>
</ul><p>都没用。社区里有人通过修改 OCLP 的 SMBIOS 伪装设置恢复了 Touch ID，但也有人改完开不了机。这台是家庭备用机，用密码解锁完全够用，就没有再折腾。</p></section><section><h3>iCloud 退不出登录，照片、音乐也打不开<a href="#icloud-退不出登录照片音乐也打不开"><span>#</span></a></h3><p>升级后想退出 iCloud，一直转圈。强制退出、重启、先关”查找我的 Mac”都不行，接着自带的照片、音乐也打不开了。</p><p>原因是退出过程卡在一半，账户状态乱了；照片和音乐启动时要读 iCloud 账户和钥匙串，也跟着卡住。</p><p>解决办法是<strong>新建一个管理员用户</strong>，用新用户登录后一切正常。说明系统没问题，只是旧用户的账户数据坏了。把旧用户删掉，再在 iPhone 的”设置 → 你的名字”设备列表里把这台 Mac 从账户中移除，就彻底干净了。</p></section><section><h3>睡眠后唤醒就死机<a href="#睡眠后唤醒就死机"><span>#</span></a></h3><p>装好用了一天，发现只要电脑进入睡眠，再唤醒就卡死：屏幕闪一下或者干脆不亮，键盘鼠标都没反应，只能长按电源键强制关机。</p><p><strong>先看崩溃日志。</strong> macOS 会把这类故障记在 <code>/Library/Logs/DiagnosticReports/</code> 里，一共找到两种文件：</p><ul>
<li><code>Kernel-*.panic</code>：内核崩溃记录</li>
<li><code>Sleep Wake Failure_*.diag</code>：睡眠唤醒失败记录</li>
</ul><p><code>.panic</code> 文件有好几 MB，第一行是一段 JSON 元信息，第二行才是正文，崩溃原因在 <code>macOSPanicString</code> 字段里，旁边还有一大段 base64 编码的现场快照。直接 <code>grep</code> 搜不到东西，用 Python 解析一下就清楚了：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>f</span><span><span>=</span><span>$(</span></span><span>ls</span><span> </span><span>-t</span><span> </span><span>/Library/Logs/DiagnosticReports/Kernel-</span><span>*</span><span>.panic</span><span> | </span><span>head</span><span> </span><span>-1</span><span>)</span></div></div><div><div><div>2</div></div><div><span>python3</span><span> </span><span>-</span><span> </span><span>"</span><span>$f</span><span>"</span><span> &lt;&lt;</span><span>'PY'</span></div></div><div><div><div>3</div></div><div><span>import json, sys</span></div></div><div><div><div>4</div></div><div><span>body = open(sys.argv[1], errors="ignore").read().split("\n", 1)[1]</span></div></div><div><div><div>5</div></div><div><span>d, _ = json.JSONDecoder().raw_decode(body)</span></div></div><div><div><div>6</div></div><div><span>print(d.get("macOSPanicString", "")[:1500])</span></div></div><div><div><div>7</div></div><div><span>PY</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>日志里能看出三件事：</p>

<table><thead><tr><th>记录</th><th>内容</th><th>说明</th></tr></thead><tbody><tr><td>内核崩溃</td><td><code>AMDFramebufferVIB::setPowerState ... timed out after 45290 ms</code></td><td>唤醒时给 AMD 独显重新上电，45 秒没完成，系统判定超时崩溃</td></tr><tr><td>唤醒失败</td><td>卡在 <code>IOGraphicsSystem</code>，等着 AMD Radeon 的驱动</td><td>和上一条是同一个问题，最后是我按电源键强制关的机</td></tr><tr><td>唤醒失败</td><td><code>Sleep Wake failure in EFI</code></td><td>只用核显时出现，在更早的固件阶段就失败了</td></tr></tbody></table><p>另外还有一次和睡眠无关的崩溃，出在 OCLP 补的无线网卡驱动（<code>AirPort.BrcmNIC</code>）上，只出现过一次，先观察。</p><p><strong>按日志试了几种设置，都不行：</strong></p><ol>
<li><strong>关掉待机</strong>（<code>sudo pmset -a standby 0</code>）。有人在 2017 款 MacBook Pro 上用这招解决了 OCLP + Sonoma 的唤醒失败，两款硬件很接近，但在我这台上没用。</li>
<li><strong>只用独显</strong>（<code>sudo pmset -a gpuswitch 1</code>）。想看看是不是双显卡切换的问题，结果更糟：直接无法唤醒。这正好和日志对上了，问题就出在 AMD 独显的唤醒上。</li>
<li><strong>只用核显</strong>（<code>sudo pmset -a gpuswitch 0</code>），并关掉休眠镜像。躲开了独显，但唤醒在固件阶段又失败了，也就是上表的第三条。</li>
</ol><p>15 寸 2016 款还有个硬件限制：雷雳接口是直接接在独显上的。我这台平时要接外接显示器，只要插上显示器，系统就会强制启用独显，所以「只用核显」这条路本来也走不通。</p><p><strong>最终方案：不让系统睡眠，只关屏幕。</strong> 既然唤醒这一步过不去，就干脆不睡：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>sudo</span><span> </span><span>pmset</span><span> </span><span>-a</span><span> </span><span>gpuswitch</span><span> </span><span>2</span></div></div><div><div><div>2</div></div><div><span>sudo</span><span> </span><span>pmset</span><span> </span><span>-a</span><span> </span><span>sleep</span><span> </span><span>0</span></div></div><div><div><div>3</div></div><div><span>sudo</span><span> </span><span>pmset</span><span> </span><span>-a</span><span> </span><span>disablesleep</span><span> </span><span>1</span></div></div><div><div><div>4</div></div><div><span>sudo</span><span> </span><span>pmset</span><span> </span><span>-a</span><span> </span><span>hibernatemode</span><span> </span><span>0</span></div></div><div><div><div>5</div></div><div><span>sudo</span><span> </span><span>pmset</span><span> </span><span>-a</span><span> </span><span>autopoweroff</span><span> </span><span>0</span></div></div><div><div><div>6</div></div><div><span>sudo</span><span> </span><span>pmset</span><span> </span><span>-a</span><span> </span><span>standby</span><span> </span><span>0</span></div></div><div><div><div>7</div></div><div><span>sudo</span><span> </span><span>pmset</span><span> </span><span>-a</span><span> </span><span>powernap</span><span> </span><span>0</span></div></div><div><div><div>8</div></div><div><span>sudo</span><span> </span><span>rm</span><span> </span><span>-f</span><span> </span><span>/var/vm/sleepimage</span></div></div><div><div><div>9</div></div><div><span>sudo</span><span> </span><span>pmset</span><span> </span><span>-a</span><span> </span><span>displaysleep</span><span> </span><span>10</span></div></div></code></pre><div><div></div><div></div></div></figure></div>

<table><thead><tr><th>设置</th><th>作用</th></tr></thead><tbody><tr><td><code>gpuswitch 2</code></td><td>恢复显卡自动切换（接显示器时本来就会用独显）</td></tr><tr><td><code>sleep 0</code>、<code>disablesleep 1</code></td><td>系统不睡眠，合盖也不睡眠</td></tr><tr><td><code>hibernatemode 0</code>、<code>autopoweroff 0</code>、<code>standby 0</code></td><td>关掉休眠镜像和深度待机，不再往硬盘写内存镜像</td></tr><tr><td><code>powernap 0</code></td><td>关掉睡眠时的后台唤醒</td></tr><tr><td><code>displaysleep 10</code></td><td>10 分钟没操作就关屏幕</td></tr></tbody></table><p><strong>熄屏要先测一下。</strong> 熄屏后再点亮，也会让显卡的显示输出断电再上电，有可能触发同样的问题。我先把熄屏时间设成 1 分钟，等屏幕熄灭后动鼠标，能正常亮起，才改回 10 分钟。如果你的机器熄屏后也会卡死，就设成 <code>displaysleep 0</code>（永不熄屏），改用显示器自己的节能功能。</p><p>改完用 <code>pmset -g</code> 检查，应该能看到 <code>SleepDisabled 1</code>、<code>sleep 0</code>、<code>hibernatemode 0</code> 这些值。</p><p>一个小坑：一开始我把带 <code>#</code> 注释的命令直接粘进终端，结果好几行报 <code>Usage: pmset &lt;options&gt;</code>。macOS 默认的 zsh 在交互模式下不把 <code>#</code> 当注释，注释文字被当成参数传给了 pmset。不带注释重新执行就好了。</p><p><strong>日常使用要注意：</strong></p><ul>
<li><strong>不要点苹果菜单里的「睡眠」</strong>，它会绕过上面的设置强制睡眠，还是会死机。</li>
<li>离开时用 <code>Control + Command + Q</code> 锁屏，锁屏不涉及睡眠。</li>
<li>长时间不用就直接关机。</li>
<li>以后升级了 OCLP，想再试试正常睡眠，把 <code>disablesleep</code> 改回 0、<code>sleep</code> 改回 1 即可。</li>
</ul></section></section>
<section><h2>六、装完之后要知道的<a href="#六装完之后要知道的"><span>#</span></a></h2><ul>
<li><strong>别删 OCLP，别动 EFI 分区</strong>：现在每次开机都要经过 OpenCore。</li>
<li><strong>少用”重置 NVRAM”</strong>：重置后可能绕过 OpenCore，系统进不去。万一进不去，开机按住 Option 选 EFI Boot。</li>
<li><strong>系统更新</strong>：14.x 的小版本更新可以装，但下载的是完整安装包；更新完显卡等补丁会失效，OCLP 会自动弹窗提示重新打补丁。大版本升级（15、26）别点。我关掉了”自动安装 macOS 更新”，只保留”安全响应和系统文件”。</li>
<li><strong>安全性略低</strong>：为了打补丁，OCLP 关掉了部分系统完整性保护（SIP），个别检测 SIP 的软件可能拒绝运行。不在这台机器上处理敏感事务就好。</li>
<li><strong>FileVault 尽量别开</strong>：打补丁时一般要求它关闭。</li>
<li><strong>系统不睡眠</strong>：这台机器睡眠后唤醒会死机，现在是「只关屏幕、系统不睡」，详见上面的<a href="#%E7%9D%A1%E7%9C%A0%E5%90%8E%E5%94%A4%E9%86%92%E5%B0%B1%E6%AD%BB%E6%9C%BA">睡眠后唤醒就死机</a>。别点苹果菜单里的「睡眠」。</li>
<li><strong>Homebrew 没有现成的安装包</strong>：Intel 芯片 + Sonoma 的组合，Homebrew 装软件要从源码编译，很慢。在这台机器上搭博客写作环境的过程记在了<a href="/posts/hexo-to-astro-migration/">《从 Hexo 迁移到 Astro》</a>里。</li>
</ul></section>
<section><h2>七、效果<a href="#七效果"><span>#</span></a></h2><p>升级后的系统是 macOS Sonoma 14.8.9。显卡、Wi-Fi、Touch Bar 都正常，只有 Touch ID 没有恢复，另外系统睡眠后无法唤醒，只能设成不睡眠、只关屏幕。新软件都能装了，接 4K 显示器看视频、写写代码、跑博客的本地预览都没问题，当一台家庭备用机绰绰有余。</p><p>一台十年前的电脑，硬件其实还够用，被淘汰的只是系统支持。OCLP 让它多用好几年。</p><blockquote><p>参考：<a href="https://dortania.github.io/OpenCore-Legacy-Patcher/SONOMA-DROP.html" target="_blank">OCLP 官方文档：macOS Sonoma</a>、<a href="https://dortania.github.io/OpenCore-Legacy-Patcher/MODELS.html" target="_blank">OCLP 支持机型</a>、<a href="https://github.com/dortania/OpenCore-Legacy-Patcher/releases" target="_blank">OCLP Releases</a>、<a href="https://hexacera.com/posts/oclp-sonoma-sleep-wake-failure" target="_blank">2017 款 MacBook Pro 在 OCLP Sonoma 上的睡眠唤醒失败</a>。</p></blockquote></section>]]></content>
    </entry>
    <entry>
      <id>https://tracerzzz.cn/posts/python-asyncio-concurrent-queries/</id>
      <title type="text">让医生别闲着：用 Python 协程把成本数据产出提速十倍</title>
      <published>2026-10-25T14:00:00.000Z</published>
      <updated>2026-10-25T14:00:00.000Z</updated>
      <author><name>tracerZzz</name></author>
      <link rel="alternate" href="https://tracerzzz.cn/posts/python-asyncio-concurrent-queries/"/>
      <summary type="text">在百度云 CDN 做成本分摊时，日均要对 Palo 发 80 万次以上的查询，大部分时间都花在等结果上。这篇用&quot;医院看病&quot;做类比讲清楚异步非阻塞，顺带对比多进程、多线程、协程三种并发方式，最后讲我先用单进程协程（asyncio + aiomysql）并发查询，再加上多进程、用队列汇总结果，把数据产出时间缩短到原来的十分之一的过程；最后对比浏览器、Node.js 和 Python asyncio 的事件循环调度原理。</summary>
      <content type="html"><![CDATA[<p></p><figure><img alt="让医生别闲着：一个诊室里一位医生在看病，旁边一排病人候诊；一位病人拿着 await 的单子去检验科，结果出来后再回来排队" loading="lazy" width="1920" height="1080" src="/_astro/clinic.qibTYcSa_Z2t5WSX.webp" /><figcaption>让医生别闲着：一个诊室里一位医生在看病，旁边一排病人候诊；一位病人拿着 await 的单子去检验科，结果出来后再回来排队</figcaption></figure><p></p>
<p>在百度云 CDN 时，我负责成本优化的数据产出：把上百个节点的带宽成本，分摊到十万多个用户、再细到每个域名，每天产出给成本看板用。这件事日均要对 Palo（也就是现在的 Apache Doris）发 80 万次以上的查询。</p>
<p>一开始我用单进程加协程并发发查询，后来又加上了<strong>多进程</strong>：每个进程里跑一个事件循环，用协程并发发查询，各进程的结果通过队列汇总，产出时间缩短到原来的十分之一左右。</p>
<p>协程这个思路我并不是在百度才想到的。早在中国电信写前端 JavaScript 的时候，我就琢磨过异步：浏览器里发一个请求出去，页面不能卡住等它回来，得先去干别的，等结果到了再回头处理。后来在百度遇到”大量查询、大部分时间在等”的问题，第一反应就是：这不就是同一件事吗。</p>
<blockquote><p>说明：这篇是 2026 年根据当年的项目回忆整理的。具体代码已经记不清，下面的代码是按通用做法写的示例，用来说明思路，不是原始实现。</p><p>2026-10-30 更新：修正了并发方案的描述（实际是单进程协程，后来加上多进程、用队列汇总结果，没有用多线程）；第一节补充了诊所运转图；第五节补充了浏览器、Node.js 和 Python asyncio 事件循环的调度原理。</p></blockquote>
<section><h2>一、医院里的异步<a href="#一医院里的异步"><span>#</span></a></h2><p>我自己理解异步，用的是医院看病的类比：</p><ul>
<li><strong>医生</strong>是 CPU，负责”看”和”判断”；</li>
<li><strong>病人</strong>是一个个任务；</li>
<li><strong>抽血、核磁、B 超</strong>这些检查科室，是读文件、查数据库、调接口这类<strong>需要等</strong>的事情。</li>
</ul><p>一个病人看病的过程，大概是：医生问诊 → 开检查单 → 病人去做检查 → 拿着结果回来 → 医生看结果、下诊断。</p><section><h3>同步阻塞：医生陪着病人去做检查<a href="#同步阻塞医生陪着病人去做检查"><span>#</span></a></h3><p>如果医生开完检查单，亲自陪病人去抽血，在化验室门口站着等结果出来，再一起回诊室，那么整个等待期间，医生什么也没干。后面排队的病人只能干等。</p><p>这就是<strong>同步阻塞</strong>：程序发出一次查询，就停在那里等结果，等到了才发下一次。一次查询如果 1 秒，其中 0.95 秒都在等数据库返回，那 CPU 有 95% 的时间是闲着的。</p></section><section><h3>异步非阻塞：开完单子就叫下一位<a href="#异步非阻塞开完单子就叫下一位"><span>#</span></a></h3><p>正常医院不是这样运转的。医生开完检查单，病人自己去排队做检查，医生马上叫下一位。等前面病人的检查结果出来了，医生看完手上这一位，再把他叫回来接着看。</p><p>这就是<strong>异步非阻塞</strong>：</p><ul>
<li><strong>非阻塞</strong>：发出查询后不原地等，马上去处理别的任务；</li>
<li><strong>异步</strong>：结果好了会有通知（叫号），再回头接着处理。</li>
</ul><p>医生始终在看病人，没有在等。病人多、检查多的时候，效率差别非常大。</p></section><section><h3>对应到代码里<a href="#对应到代码里"><span>#</span></a></h3>

<table><thead><tr><th>医院</th><th>asyncio</th></tr></thead><tbody><tr><td>医生</td><td>CPU（一个线程）</td></tr><tr><td>病人</td><td>协程（coroutine），一个个 <code>async def</code> 函数</td></tr><tr><td>开检查单，让病人去排队</td><td><code>await</code>：发起一个要等的操作，把控制权交出去</td></tr><tr><td>医生自己叫号</td><td>事件循环（event loop）：谁的结果好了就切回谁</td></tr><tr><td>检查科室同时能接待几个人</td><td>并发上限（连接池大小、信号量）</td></tr></tbody></table><p>要注意，这家诊所<strong>没有专门的分诊护士</strong>：事件循环和协程跑在同一个线程上。医生看完一个病人，自己看看候诊区、接一下检验科的电话，再叫下一位；候诊区空了，就守着电话等。所以医生一旦被某个病人拖住，连叫号都没人做了。</p><p>把整家诊所画出来，每个位置都标上了对应的模块，Python 和 JavaScript 都有：</p><p></p><figure><img alt="诊所运转图：一个诊室是一个线程，候诊区是就绪队列，医生是执行代码的线程，看病人是执行协程，两个病人之间自己叫号是事件循环，唯一的笔是 GIL；检查科室里，结果通知中心是 epoll（浏览器里是浏览器的其他线程），Palo 是真正做检查的地方" loading="lazy" width="960" height="500" src="/_astro/asyncio-clinic.DuGzrikQ_Z1OzHGx.svg" /><figcaption>诊所运转图：一个诊室是一个线程，候诊区是就绪队列，医生是执行代码的线程，看病人是执行协程，两个病人之间自己叫号是事件循环，唯一的笔是 GIL；检查科室里，结果通知中心是 epoll（浏览器里是浏览器的其他线程），Palo 是真正做检查的地方</figcaption></figure><p></p><p>有一个关键前提：<strong>病人得自己去排队，不能拖着医生走</strong>。如果某个”检查”是医生必须亲自陪着做的（在协程里调用了一个同步阻塞的函数），医生就又被拖住了，后面所有人都得等。这一点后面讲代码时还会提到。</p></section></section>
<section><h2>二、并发的基础：进程、线程、协程<a href="#二并发的基础进程线程协程"><span>#</span></a></h2><p>在讲项目之前，先把几种并发方式捋一下。</p><section><h3>并发和并行<a href="#并发和并行"><span>#</span></a></h3><ul>
<li><strong>并发</strong>（concurrency）：一段时间内同时处理多件事，但不一定在同一时刻都在做。一个医生轮流看好几个病人，是并发。</li>
<li><strong>并行</strong>（parallelism）：同一时刻真的在同时做多件事。好几个医生各看各的病人，是并行。</li>
</ul><p>并行需要多个 CPU 核心；并发一个核心也能做到。</p></section><section><h3>三种方式<a href="#三种方式"><span>#</span></a></h3><p><strong>多进程</strong>：开好几个诊室，每个诊室一个医生，各自有一套病历柜。</p><ul>
<li>进程是操作系统分配资源的单位，每个进程有独立的内存空间。</li>
<li>进程之间互不干扰，一个崩了不影响别的；但要交换数据就得通过管道、队列、共享内存这类方式，比较麻烦。</li>
<li>创建和切换的开销最大，能真正利用多核并行。</li>
</ul><p><strong>多线程</strong>：一个诊室里有好几个医生，共用一套病历柜。</p><ul>
<li>线程是操作系统调度的单位，同一进程里的线程共享内存。</li>
<li>共享数据很方便，但多个线程同时改同一份数据会出问题，需要加锁。</li>
<li>线程由操作系统调度，<strong>抢占式</strong>切换：操作系统说切就切，线程自己控制不了。</li>
</ul><p><strong>协程</strong>：只有一个医生，但他自己安排看病顺序，病人去做检查时就先看下一个。</p><ul>
<li>协程是用户态的”轻量级线程”，由程序自己调度，操作系统不知道它的存在。</li>
<li><strong>协作式</strong>切换：只在 <code>await</code> 这种明确的地方切换，所以不会在一行代码执行到一半时被切走，大多数情况下不用加锁。</li>
<li>一个协程只是一个小对象，开几万个也没压力；线程每个都要占一块栈内存，开几千个就吃力了。</li>
<li>一个协程如果不主动让出（比如执行了一段很长的计算，或者调用了阻塞函数），其他协程就都得等着。</li>
</ul></section><section><h3>Python 的特殊情况：GIL<a href="#python-的特殊情况gil"><span>#</span></a></h3><p>Python（默认的 CPython 解释器）有一把<strong>全局解释器锁</strong>（GIL）：同一时刻，一个进程里只有一个线程能执行 Python 代码。</p><p>套到医院里，就像一个诊室里虽然有好几个医生，但只有一支笔，谁拿到笔谁才能写病历。所以在 Python 里：</p><ul>
<li><strong>CPU 密集型任务</strong>（大量计算）：多线程没有用，几个线程抢一支笔，还多了切换开销。要用<strong>多进程</strong>，开好几个诊室，才能真正用上多核。</li>
<li><strong>IO 密集型任务</strong>（大量等待）：线程在等 IO 的时候会把 GIL 让出来，所以多线程是有用的；协程也有用，而且更轻。</li>
</ul><blockquote><p>Python 3.13 开始提供了可选的去掉 GIL 的构建版本，但默认版本仍然有 GIL，上面的结论在大多数环境里依然成立。</p></blockquote></section><section><h3>对比<a href="#对比"><span>#</span></a></h3>

<table><thead><tr><th></th><th>多进程</th><th>多线程</th><th>协程</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>小</td></tr><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>Python 里受 GIL 限制</td><td>不能（单线程）</td></tr><tr><td>适合</td><td>CPU 密集</td><td>IO 密集，且要用同步库</td><td>大量 IO 密集任务</td></tr></tbody></table><p>实际项目里经常组合使用：多进程把多核用满，每个进程里再跑一个事件循环处理大量 IO。</p></section></section>
<section><h2>三、项目里的问题：80 万次查询，大部分时间在等<a href="#三项目里的问题80-万次查询大部分时间在等"><span>#</span></a></h2><p>回到百度的成本数据产出。查询量大，有几个原因叠在一起：</p><ul>
<li><strong>数据多，而且大多没有聚合。</strong> 带宽数据是细粒度的明细，要分摊到用户、域名，就得按各种维度去查。</li>
<li><strong>指标多。</strong> 带宽、流量、请求数、各计费方式的计费值，每个都要单独算。</li>
<li><strong>存储分散。</strong> 数据分布在不同的表、不同的库里，没办法一条 SQL 全拿出来。</li>
</ul><p>结果就是：一天要对 Palo 发 80 万次以上的查询。每次查询本身并不复杂，但网络往返加上等 Palo 返回结果，总要等上一会儿。如果一条一条查，大部分时间都是程序在”站在化验室门口等”。</p><p>这正是协程最适合的场景：<strong>任务很多，彼此独立，每个任务大部分时间在等 IO。</strong></p><section><h3>优化分两半<a href="#优化分两半"><span>#</span></a></h3><p>成本产出的优化其实做了两方面：</p><ul>
<li><strong>让每次检查更快</strong>：建上卷表（预聚合）、加索引，减少单次查询扫描的数据量。</li>
<li><strong>让医生不闲着</strong>：用协程并发发查询，在等一个结果的时候去发别的查询。</li>
</ul><p>这篇只讲第二部分。</p></section><section><h3>为什么没用多线程<a href="#为什么没用多线程"><span>#</span></a></h3><p>说到并发，Python 里最先想到的通常是多线程。查询是 IO 密集的，线程等结果时会让出 GIL，理论上是有用的。但当时我已经知道 GIL 的存在，没有走多线程这条路，一开始就选了协程。</p><p>写这篇文章时，我把多线程为什么不合适的账重新算了一遍，主要是下面三点。</p><p><strong>1. 一个线程同一时刻只有一个查询在路上。</strong></p><p>线程发出查询后就阻塞在那里，直到结果回来才能发下一个。所以多线程的并发度就等于线程数。</p><p>可以算一笔账（数字是举例，不是当时的实测值）：假设一次查询从发出到拿到结果平均要 0.5 秒，开 50 个线程，每秒最多完成</p><span><span><span>同时在路上的查询数单次耗时=500.5 s=100 次/秒\frac{\text{同时在路上的查询数}}{\text{单次耗时}} = \frac{50}{0.5\,\text{s}} = 100\ \text{次/秒}</span><span><span><span></span><span><span></span><span><span><span><span><span><span></span><span><span><span>单次耗时</span></span></span></span><span><span></span><span></span></span><span><span></span><span><span><span>同时在路上的查询数</span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span><span></span></span><span></span><span>=</span><span></span></span><span><span></span><span><span></span><span><span><span><span><span><span></span><span><span>0.5</span><span></span><span><span>s</span></span></span></span><span><span></span><span></span></span><span><span></span><span><span>50</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span><span></span></span><span></span><span>=</span><span></span></span><span><span></span><span>100</span><span> </span><span><span>次</span><span>/</span><span>秒</span></span></span></span></span></span><p>80 万次查询就要 8000 秒，两个多小时。想再快，只能把同时在路上的查询变多，也就是加线程。</p><p><strong>2. 线程加不上去。</strong></p><p>每个线程都要占一块栈内存（Linux 上默认按 8MB 预留虚拟内存），由操作系统调度。线程开到几百个时，创建、切换、调度本身就成了负担，加线程的收益越来越小。</p><p><strong>3. 线程越多，GIL 抢得越凶。</strong></p><p>等 IO 的时候 GIL 会让出来，可查询结果回来之后，解析结果、组装数据、做计算都是 Python 代码，必须拿到 GIL 才能执行。常用的 MySQL 驱动 PyMySQL 是纯 Python 实现的（aiomysql 底层用的也是它），连解析 MySQL 协议、把返回的字节转成 Python 对象都要占着 GIL。成本数据的查询结果往往不小，这部分开销并不低。</p><p>线程一多，大家的结果差不多同时回来，排队等同一把锁；Python 还会每隔一小段时间强制让持锁的线程交出 GIL，切换越频繁，浪费越多。结果是加了线程，真正干活的时间却没有成比例增加。</p><p>用医院来说：多线程是多请了几个医生，但每个医生还是陪着自己的病人去做检查，一次只能陪一个；医生多了，诊室里那支唯一的笔又不够用，大家先在抢笔。</p><p>协程正好解决第 1、2 点：一个线程里可以同时有几十、上百个查询在路上，而协程只是一个小对象，开多少都不会给操作系统添负担。第 3 点协程解决不了，这也是后来又加上多进程的原因。</p></section></section>
<section><h2>四、asyncio + aiomysql<a href="#四asyncio--aiomysql"><span>#</span></a></h2><p>Palo 兼容 MySQL 协议，可以直接用 MySQL 客户端连。要在协程里查，就得用异步的 MySQL 客户端：<strong>aiomysql</strong>。</p><p>这里正好对应前面说的那个前提：如果在协程里用普通的 <code>pymysql</code>，执行查询时会阻塞整个线程，相当于医生又亲自陪病人去抽血了，所有协程都会被卡住，并发效果为零。aiomysql 在等结果时会把控制权交还给事件循环，这才是”病人自己去排队”。</p><p>一个简化的示例：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>import</span><span> asyncio</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>import</span><span> aiomysql</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span><span>CONCURRENCY</span><span> </span><span>=</span><span> </span></span><span>50</span><span>  </span><span># 同时最多跑多少个查询，按 Palo 监控调</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div>
</div></div><div><div><div>8</div></div><div><span>async</span><span> </span><span>def</span><span> </span><span>run_query</span><span>(</span><span>pool</span><span>,</span><span><span> </span><span>sem</span></span><span>,</span><span><span> </span><span>sql</span></span><span>,</span><span><span> </span><span>params</span></span><span>):</span></div></div><div><div><div>9</div></div><div><span>    </span><span>async</span><span> </span><span>with</span><span> sem:  </span><span># 超过并发上限的查询在这里排队</span></div></div><div><div><div>10</div></div><div><span>        </span><span>async</span><span> </span><span>with</span><span><span> pool.</span><span>acquire</span><span>() </span></span><span>as</span><span> conn:</span></div></div><div><div><div>11</div></div><div><span>            </span><span>async</span><span> </span><span>with</span><span><span> conn.</span><span>cursor</span><span>(aiomysql.DictCursor) </span></span><span>as</span><span> cur:</span></div></div><div><div><div>12</div></div><div><span>                </span><span>await</span><span><span> cur.</span><span>execute</span><span>(sql, params)  </span></span><span># 交出控制权，等 Palo 返回</span></div></div><div><div><div>13</div></div><div><span>                </span><span>return</span><span> </span><span>await</span><span><span> cur.</span><span>fetchall</span><span>()</span></span></div></div><div><div><div>14</div></div><div>
</div></div><div><div><div>15</div></div><div>
</div></div><div><div><div>16</div></div><div><span>async</span><span> </span><span>def</span><span> </span><span>main</span><span>(</span><span>queries</span><span>):</span></div></div><div><div><div>17</div></div><div><span><span>    </span></span><span>pool </span><span>=</span><span> </span><span>await</span><span><span> aiomysql.</span><span>create_pool</span><span>(</span></span></div></div><div><div><div>18</div></div><div><span>        </span><span>host</span><span>=</span><span>"palo-fe.example"</span><span>, </span><span>port</span><span>=</span><span>9030</span><span>,</span></div></div><div><div><div>19</div></div><div><span>        </span><span>user</span><span>=</span><span>"reader"</span><span>, </span><span>password</span><span>=</span><span>"***"</span><span>, </span><span>db</span><span>=</span><span>"cost"</span><span>,</span></div></div><div><div><div>20</div></div><div><span>        </span><span>minsize</span><span>=</span><span>1</span><span>, </span><span>maxsize</span><span><span>=</span><span>CONCURRENCY</span><span>,</span></span></div></div><div><div><div>21</div></div><div><span><span>    </span></span><span>)</span></div></div><div><div><div>22</div></div><div><span><span>    </span></span><span>sem </span><span>=</span><span> asyncio.</span><span>Semaphore</span><span>(</span><span>CONCURRENCY</span><span>)</span></div></div><div><div><div>23</div></div><div><span>    </span><span>try</span><span>:</span></div></div><div><div><div>24</div></div><div><span><span>        </span></span><span>tasks </span><span>=</span><span> [</span><span>run_query</span><span>(pool, sem, sql, params) </span><span>for</span><span> sql, params </span><span>in</span><span> queries]</span></div></div><div><div><div>25</div></div><div><span>        </span><span>return</span><span> </span><span>await</span><span><span> asyncio.</span><span>gather</span><span>(*tasks, </span></span><span>return_exceptions</span><span>=</span><span>True</span><span>)</span></div></div><div><div><div>26</div></div><div><span>    </span><span>finally</span><span>:</span></div></div><div><div><div>27</div></div><div><span><span>        </span></span><span>pool.</span><span>close</span><span>()</span></div></div><div><div><div>28</div></div><div><span>        </span><span>await</span><span><span> pool.</span><span>wait_closed</span><span>()</span></span></div></div><div><div><div>29</div></div><div>
</div></div><div><div><div>30</div></div><div>
</div></div><div><div><div>31</div></div><div><span><span>results </span><span>=</span><span> asyncio.</span><span>run</span><span>(</span><span>main</span><span>(queries))</span></span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>几个要点：</p><ul>
<li><strong>连接池</strong>：建立数据库连接本身也要时间，用连接池复用，不用每次查询都重新连。</li>
<li><strong>信号量控制并发</strong>：<code>asyncio.Semaphore</code> 规定同时最多有多少个查询在跑，相当于检查科室同时能接待的人数。这里连接池的 <code>maxsize</code> 也能起到同样的作用，写成信号量是为了让”并发上限”这件事更直观。</li>
<li><strong><code>gather</code> 收集结果</strong>：所有查询一起提交，全部完成后按原顺序拿到结果。<code>return_exceptions=True</code> 让单个查询失败不会中断其他查询，失败的再单独重试。</li>
<li>查询数量特别多时，一次性创建几十万个协程对象会占不少内存。可以分批提交，或者改成”固定数量的 worker 从队列里取任务”的写法，效果一样。</li>
</ul><section><h3>从单进程到多进程<a href="#从单进程到多进程"><span>#</span></a></h3><p>一开始只有一个进程、一个事件循环，就是上面这段代码的样子。协程让同时在路上的查询从一个变成了几十个，“等”的问题解决了。</p><p>但接下来就碰到了前面说的第 3 点：查询结果回来以后，解析结果、整理数据、做计算都要执行 Python 代码，一个进程受 GIL 限制，最多只能用满一个核。查询并发越高，结果回来得越密集，这部分 CPU 工作就越成为瓶颈。</p><p>所以后来又加上了<strong>多进程</strong>：每个进程有自己独立的解释器和 GIL，可以各自用满一个核；每个进程里再跑一个事件循环，用协程并发发查询。各进程的结果通过<strong>队列</strong>（<code>multiprocessing.Queue</code>）传回主进程汇总。</p><p>任务是按域名拆的：把全部域名列表按下标切成几段，每个进程负责一段域名；进程里再把这批域名要查的各项指标、各张表的查询，用协程并发跑完。整体结构如下：</p><div><div><div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>第 1 段域名</p></span></div><div><span><p>第 2 段域名</p></span></div><div><span><p>第 N 段域名</p></span></div><div><span><p>并发查询</p></span></div><div><span><p>并发查询</p></span></div><div><span><p>并发查询</p></span></div><div><span><p>结果放入队列</p></span></div><div><span><p>结果放入队列</p></span></div><div><span><p>结果放入队列</p></span></div><div><span><p>进程 N</p></span></div><div><span></span></div><div><span><p>事件循环</p></span></div><div><span><p>协程 × 50<br /><br />aiomysql 连接池 + 信号量</p></span></div><div><span><p>进程 2</p></span></div><div><span></span></div><div><span><p>事件循环</p></span></div><div><span><p>协程 × 50<br /><br />aiomysql 连接池 + 信号量</p></span></div><div><span><p>进程 1</p></span></div><div><span></span></div><div><span><p>事件循环</p></span></div><div><span><p>协程 × 50<br /><br />aiomysql 连接池 + 信号量</p></span></div><div><span><p>全部域名列表</p></span></div><div><span><p>按下标切成 N 段</p></span></div><div><span><p>Palo</p></span></div><div><span><p>multiprocessing.Queue</p></span></div><div><span><p>主进程汇总</p></span></div><div><span><p>写入成本表，看板 T+1 产出</p></span></div>
</div><div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>第 1 段域名</p></span></div><div><span><p>第 2 段域名</p></span></div><div><span><p>第 N 段域名</p></span></div><div><span><p>并发查询</p></span></div><div><span><p>并发查询</p></span></div><div><span><p>并发查询</p></span></div><div><span><p>结果放入队列</p></span></div><div><span><p>结果放入队列</p></span></div><div><span><p>结果放入队列</p></span></div><div><span><p>进程 N</p></span></div><div><span></span></div><div><span><p>事件循环</p></span></div><div><span><p>协程 × 50<br /><br />aiomysql 连接池 + 信号量</p></span></div><div><span><p>进程 2</p></span></div><div><span></span></div><div><span><p>事件循环</p></span></div><div><span><p>协程 × 50<br /><br />aiomysql 连接池 + 信号量</p></span></div><div><span><p>进程 1</p></span></div><div><span></span></div><div><span><p>事件循环</p></span></div><div><span><p>协程 × 50<br /><br />aiomysql 连接池 + 信号量</p></span></div><div><span><p>全部域名列表</p></span></div><div><span><p>按下标切成 N 段</p></span></div><div><span><p>Palo</p></span></div><div><span><p>multiprocessing.Queue</p></span></div><div><span><p>主进程汇总</p></span></div><div><span><p>写入成本表，看板 T+1 产出</p></span></div>
</div></div></div><p>对应的代码：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>import</span><span> multiprocessing </span><span>as</span><span> mp</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span><span>PROCESSES</span><span> </span><span>=</span><span> </span></span><span>8</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>def</span><span> </span><span>worker</span><span>(</span><span>domains</span><span>,</span><span><span> </span><span>queue</span></span><span>):</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>queries </span><span>=</span><span> </span><span>build_queries</span><span>(domains)      </span><span># 这批域名要查的所有指标、所有表</span></div></div><div><div><div>8</div></div><div><span><span>    </span></span><span>results </span><span>=</span><span> asyncio.</span><span>run</span><span>(</span><span>main</span><span>(queries))  </span><span># 每个进程有自己的事件循环和连接池</span></div></div><div><div><div>9</div></div><div><span><span>    </span></span><span>queue.</span><span>put</span><span>(results)                    </span><span># 结果放进队列，交给主进程</span></div></div><div><div><div>10</div></div><div>
</div></div><div><div><div>11</div></div><div>
</div></div><div><div><div>12</div></div><div><span>if</span><span> </span><span>__name__</span><span><span> </span><span>==</span><span> </span></span><span>"__main__"</span><span>:</span></div></div><div><div><div>13</div></div><div><span><span>    </span></span><span>size </span><span>=</span><span> (</span><span>len</span><span><span>(domains) </span><span>+</span><span> </span><span>PROCESSES</span><span> </span><span>-</span><span> </span></span><span>1</span><span><span>) </span><span>//</span><span> </span><span>PROCESSES</span></span></div></div><div><div><div>14</div></div><div><span><span>    </span></span><span>chunks </span><span>=</span><span> [domains[i:i </span><span>+</span><span> size] </span><span>for</span><span> i </span><span>in</span><span> </span><span>range</span><span>(</span><span>0</span><span>, </span><span>len</span><span>(domains), size)]  </span><span># 按下标切分</span></div></div><div><div><div>15</div></div><div>
</div></div><div><div><div>16</div></div><div><span><span>    </span></span><span>queue </span><span>=</span><span> mp.</span><span>Queue</span><span>()</span></div></div><div><div><div>17</div></div><div><span><span>    </span></span><span>procs </span><span>=</span><span> [mp.</span><span>Process</span><span>(</span><span>target</span><span><span>=</span><span>worker, </span></span><span>args</span><span><span>=</span><span>(chunk, queue)) </span></span><span>for</span><span> chunk </span><span>in</span><span> chunks]</span></div></div><div><div><div>18</div></div><div><span>    </span><span>for</span><span> p </span><span>in</span><span> procs:</span></div></div><div><div><div>19</div></div><div><span><span>        </span></span><span>p.</span><span>start</span><span>()</span></div></div><div><div><div>20</div></div><div>
</div></div><div><div><div>21</div></div><div><span><span>    </span></span><span>all_results </span><span>=</span><span> [queue.</span><span>get</span><span>() </span><span>for</span><span> _ </span><span>in</span><span> procs]  </span><span># 先把结果都取出来</span></div></div><div><div><div>22</div></div><div><span>    </span><span>for</span><span> p </span><span>in</span><span> procs:</span></div></div><div><div><div>23</div></div><div><span><span>        </span></span><span>p.</span><span>join</span><span>()                                 </span><span># 再等子进程退出</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>几个要点：</p><ul>
<li><strong>按域名拆，进程之间几乎不用通信</strong>。每个域名的成本只和它自己的数据有关，各段之间互不依赖，进程之间不共享状态、不用加锁，只在最后通过队列交一次结果。</li>
<li><strong>先取结果，再 <code>join</code></strong>。子进程往队列里放了大量数据时，要等这些数据被取走才能真正退出。如果主进程先 <code>join</code> 等子进程退出，子进程又在等数据被取走，两边就会互相卡住。Python 官方文档专门提醒过这一点。</li>
<li><strong>入口要放在 <code>if __name__ == "__main__":</code> 下面</strong>。macOS 和 Windows 上默认用 spawn 方式创建子进程，子进程会重新导入主模块，不加这句会反复创建进程。</li>
<li><strong>进程数一般不超过 CPU 核数</strong>。进程是为了用满多核，超过核数就只剩切换开销了。</li>
</ul><p>还是用上面的账：8 个进程、每个进程 50 个并发，同时在路上的查询就是 400 个；而且结果解析和计算分到了 8 个核上并行，不再挤在一把 GIL 下排队。</p><p>回头看，有一处可以改进：队列里传的是 Python 对象，放进去时要序列化（pickle），主进程取出来再反序列化，结果数据量大时这部分开销不小，而且所有数据最后都压在主进程一个核上汇总。如果再做一次，我会让每个进程直接把自己那段域名的结果写进成本表，主进程只负责分配任务和确认是否都完成，省掉跨进程搬运大量数据。</p></section><section><h3>并发开多大<a href="#并发开多大"><span>#</span></a></h3><p>并发不是越大越好。医生不闲着了，压力就转移到了检查科室：同时发太多查询，Palo 的 CPU、内存、查询队列会扛不住，查询反而变慢，甚至失败。</p><p>我的做法是<strong>看着 Palo 的监控调</strong>：逐步加大并发，观察 Palo 的 CPU、内存和查询耗时，停在不触发告警线的位置。当时这套 Palo 是成本数据专用的服务器，不用担心影响其他业务，所以可以放心把它的余量用起来。</p></section><section><h3>效果<a href="#效果"><span>#</span></a></h3><p>改成多进程 + 协程之后，数据产出时间缩短到原来的十分之一左右。</p><p>为什么是十倍，而不是并发数那么多倍？因为瓶颈转移了：</p><ul>
<li>查询并发起来以后，Palo 本身的处理能力成了上限，再加并发也不会更快；</li>
<li>结果解析、计算、写库这些 CPU 工作虽然分到了多个进程，但进程数受核数限制，不可能无限扩。</li>
</ul><p>这也说明，协程解决的是”等”的问题，多进程解决的是”算”的问题，两者各管一半，组合起来才把一台机器的能力用满。</p></section></section>
<section><h2>五、从 JavaScript 到 Python：事件循环是怎么调度的<a href="#五从-javascript-到-python事件循环是怎么调度的"><span>#</span></a></h2><p>最后说回开头。我在电信写前端时第一次认真想异步，是因为浏览器的 JavaScript 只有一个线程：页面渲染、响应点击、发请求都在这一个线程上。如果发一个请求就卡住等它回来，整个页面都会冻住。所以 JavaScript 从一开始就是异步的：先是回调函数，后来是 Promise，再后来是 <code>async</code>/<code>await</code>。</p><p>Python 的 asyncio 和它是同一套思想，连 <code>async</code>/<code>await</code> 这两个关键字都一样。但”同一套思想”具体是什么，每次循环里到底按什么顺序执行，当时我只有个模糊的印象。这一节把浏览器、Node.js 和 Python asyncio 的事件循环放在一起讲清楚。</p><section><h3>共同的骨架<a href="#共同的骨架"><span>#</span></a></h3><p>三者的结构是一样的，用医院的类比就是：</p><ul>
<li><strong>只有一个医生</strong>：只有一个线程在执行代码，同一时刻只做一件事；</li>
<li><strong>病人都在排队</strong>：要执行的回调放在队列里；</li>
<li><strong>医生自己叫号</strong>：事件循环不断地从队列里取一个任务执行，执行完再取下一个；</li>
<li><strong>检查交给别人去等</strong>：发请求、等定时器这类事情，由医生以外的”科室”负责等，结果好了再把病人送回候诊区。</li>
</ul><p>区别主要在两点：<strong>谁负责”等”</strong>，以及<strong>排队的规则</strong>。</p></section><section><h3>浏览器的事件循环<a href="#浏览器的事件循环"><span>#</span></a></h3><p>浏览器里，JavaScript 运行在一个主线程上，但浏览器本身是多线程的：定时器计时、网络请求、用户点击的监听，都由浏览器的其他线程负责。它们完成后，把对应的回调放进任务队列，等主线程来执行。</p><p>队列分两种：</p><ul>
<li><strong>宏任务</strong>（task）：<code>setTimeout</code>、<code>setInterval</code> 的回调，网络请求完成、用户点击这些事件的回调，以及整段 <code>&lt;script&gt;</code> 代码本身；</li>
<li><strong>微任务</strong>（microtask）：<code>Promise.then</code>、<code>await</code> 之后的代码、<code>queueMicrotask</code>。</li>
</ul><p>每一轮循环的规则是：</p><div><div><div><div><span><p>完成后放入回调</p></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>是</p></span></div><div><span><p>否</p></span></div><div><span></span></div><div><span><p>浏览器的其他线程<br /><br />定时器、网络请求、用户事件</p></span></div><div><span><p>宏任务队列</p></span></div><div><span><p>取出一个宏任务</p></span></div><div><span><p>执行到底，中途不会被打断</p></span></div><div><span><p>清空微任务队列<br /><br />执行中新产生的微任务也一并执行完</p></span></div><div><span><p>到渲染时机了吗？<br /><br />一般约每 16.7 毫秒一次</p></span></div><div><span><p>执行 requestAnimationFrame 回调<br /><br />计算样式、布局、绘制</p></span></div>
</div><div><div><span><p>完成后放入回调</p></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>是</p></span></div><div><span><p>否</p></span></div><div><span></span></div><div><span><p>浏览器的其他线程<br /><br />定时器、网络请求、用户事件</p></span></div><div><span><p>宏任务队列</p></span></div><div><span><p>取出一个宏任务</p></span></div><div><span><p>执行到底，中途不会被打断</p></span></div><div><span><p>清空微任务队列<br /><br />执行中新产生的微任务也一并执行完</p></span></div><div><span><p>到渲染时机了吗？<br /><br />一般约每 16.7 毫秒一次</p></span></div><div><span><p>执行 requestAnimationFrame 回调<br /><br />计算样式、布局、绘制</p></span></div>
</div></div></div><p>一个经典的例子：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>console</span><span>.</span></span><span>log</span><span>(</span><span>'1'</span><span>);</span></div></div><div><div><div>2</div></div><div><span>setTimeout</span><span>(() </span><span>=&gt;</span><span><span> </span><span>console</span><span>.</span></span><span>log</span><span>(</span><span>'4'</span><span>), </span><span>0</span><span>);</span></div></div><div><div><div>3</div></div><div><span>Promise</span><span>.</span><span>resolve</span><span>().</span><span>then</span><span>(() </span><span>=&gt;</span><span><span> </span><span>console</span><span>.</span></span><span>log</span><span>(</span><span>'3'</span><span>));</span></div></div><div><div><div>4</div></div><div><span><span>console</span><span>.</span></span><span>log</span><span>(</span><span>'2'</span><span>);</span></div></div><div><div><div>5</div></div><div><span>// 输出：1 2 3 4</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>整段脚本是第一个宏任务，先把同步代码执行完，打印 1 和 2；然后清空微任务队列，打印 3；<code>setTimeout</code> 的回调是下一个宏任务，最后才打印 4。即使延迟写的是 0，它也要排到下一轮。</p><p>套回医院：</p><ul>
<li><strong>宏任务</strong>是医生叫号叫来的新病人；</li>
<li><strong>微任务</strong>是当前病人看完之前补开的单子，必须马上处理完，才能叫下一位；</li>
<li><strong>渲染</strong>是大厅的叫号屏，大约每 16.7 毫秒刷新一次，但只有医生手上空下来时才能刷新。</li>
</ul><p>这就解释了前端最常见的卡顿：一段 JavaScript 执行太久，或者微任务一个接一个地产生停不下来，医生一直腾不出手，叫号屏就不刷新，页面看起来就冻住了。</p></section><section><h3>Node.js 和浏览器一样吗<a href="#nodejs-和浏览器一样吗"><span>#</span></a></h3><p><strong>不完全一样</strong>。Node.js 是同一个思想，但实现不同：它的事件循环由 libuv 提供，负责”等”的不是浏览器的线程，而是操作系统。网络 IO 交给操作系统的 IO 多路复用（Linux 上是 epoll）；文件读写这类没法这样等的操作，交给 libuv 的一个后台线程池（默认 4 个线程）。</p><p>Node 的每一轮循环分成几个<strong>阶段</strong>，按顺序轮转：</p><ol>
<li><strong>timers</strong>：执行到期的 <code>setTimeout</code>、<code>setInterval</code> 回调；</li>
<li><strong>pending callbacks</strong>：执行上一轮延后的部分 IO 回调；</li>
<li><strong>poll</strong>：通过 epoll 等待 IO 事件，并执行 IO 回调。等多久取决于最近的定时器和有没有 <code>setImmediate</code>；</li>
<li><strong>check</strong>：执行 <code>setImmediate</code> 的回调；</li>
<li><strong>close callbacks</strong>：执行连接关闭之类的回调。</li>
</ol><p>（中间还有两个内部使用的阶段，平时接触不到，这里省略。）</p><p>微任务的规则和浏览器类似：每执行完一个回调，就先清空微任务队列。Node 多了一个 <code>process.nextTick</code> 队列，它比 Promise 的微任务还优先。</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>fs</span><span> </span><span>=</span><span> </span><span>require</span><span>(</span><span>'fs'</span><span>);</span></div></div><div><div><div>2</div></div><div><span>setTimeout</span><span>(() </span><span>=&gt;</span><span><span> </span><span>console</span><span>.</span></span><span>log</span><span>(</span><span>'setTimeout'</span><span>), </span><span>0</span><span>);</span></div></div><div><div><div>3</div></div><div><span>setImmediate</span><span>(() </span><span>=&gt;</span><span><span> </span><span>console</span><span>.</span></span><span>log</span><span>(</span><span>'setImmediate'</span><span>));</span></div></div><div><div><div>4</div></div><div><span>Promise</span><span>.</span><span>resolve</span><span>().</span><span>then</span><span>(() </span><span>=&gt;</span><span><span> </span><span>console</span><span>.</span></span><span>log</span><span>(</span><span>'Promise.then'</span><span>));</span></div></div><div><div><div>5</div></div><div><span><span>process</span><span>.</span></span><span>nextTick</span><span>(() </span><span>=&gt;</span><span><span> </span><span>console</span><span>.</span></span><span>log</span><span>(</span><span>'nextTick'</span><span>));</span></div></div><div><div><div>6</div></div><div><span><span>fs</span><span>.</span></span><span>readFile</span><span><span>(</span><span>__filename</span><span>, () </span></span><span>=&gt;</span><span> {</span></div></div><div><div><div>7</div></div><div><span>  </span><span>setTimeout</span><span>(() </span><span>=&gt;</span><span><span> </span><span>console</span><span>.</span></span><span>log</span><span>(</span><span>'IO 回调里的 setTimeout'</span><span>), </span><span>0</span><span>);</span></div></div><div><div><div>8</div></div><div><span>  </span><span>setImmediate</span><span>(() </span><span>=&gt;</span><span><span> </span><span>console</span><span>.</span></span><span>log</span><span>(</span><span>'IO 回调里的 setImmediate'</span><span>));</span></div></div><div><div><div>9</div></div><div><span>});</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>我在 Node 22 上跑的输出是：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>nextTick</span></div></div><div><div><div>2</div></div><div><span>Promise.then</span></div></div><div><div><div>3</div></div><div><span>setTimeout</span></div></div><div><div><div>4</div></div><div><span>setImmediate</span></div></div><div><div><div>5</div></div><div><span>IO 回调里的 setImmediate</span></div></div><div><div><div>6</div></div><div><span>IO 回调里的 setTimeout</span></div></div></code></pre><div><div></div><div></div></div></figure></div><ul>
<li><code>nextTick</code> 和 <code>Promise.then</code> 在主代码执行完后马上执行，<code>nextTick</code> 在前；</li>
<li>主代码里的 <code>setTimeout</code> 和 <code>setImmediate</code> 谁先谁后，Node 官方文档说是<strong>不保证</strong>的，取决于进入循环时定时器是否已经到期；</li>
<li>在 IO 回调里就是确定的：IO 回调在 poll 阶段执行，接下来就是 check 阶段，所以 <code>setImmediate</code> 一定先于下一轮 timers 阶段的 <code>setTimeout</code>。</li>
</ul><p>两者的区别对比：</p>

<table><thead><tr><th></th><th>浏览器</th><th>Node.js</th></tr></thead><tbody><tr><td>谁负责”等”</td><td>浏览器的其他线程</td><td>操作系统（epoll），文件 IO 用线程池</td></tr><tr><td>宏任务怎么排</td><td>浏览器从各个任务队列里选</td><td>按固定阶段轮转</td></tr><tr><td>微任务</td><td>Promise、<code>queueMicrotask</code></td><td>同上，另有更优先的 <code>process.nextTick</code></td></tr><tr><td>特有的 API</td><td><code>requestAnimationFrame</code>，有渲染步骤</td><td><code>setImmediate</code>，没有渲染</td></tr></tbody></table><p>如果还用医院的类比：浏览器是一家带叫号屏的门诊，Node.js 则是医生<strong>按固定顺序轮流叫号</strong>，先叫定时复诊的，再叫检查结果回来的，再叫插队登记的。</p></section><section><h3>Python asyncio 的事件循环<a href="#python-asyncio-的事件循环"><span>#</span></a></h3><p>asyncio 和 Node.js 更像：负责”等”的也是操作系统。它通过标准库的 <code>selectors</code> 模块使用 IO 多路复用，Linux 上是 epoll，macOS 上是 kqueue（Windows 上默认是另一种基于 IOCP 的实现）。</p><p>asyncio 的事件循环里有两样东西：</p><ul>
<li><strong>就绪队列</strong>：马上可以执行的回调，按先进先出排队；</li>
<li><strong>定时器堆</strong>：按到期时间排序的回调，比如 <code>asyncio.sleep</code> 登记的唤醒。</li>
</ul><p>每一轮循环是这样的：</p><div><div><div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>计算等待时长<br /><br />就绪队列不为空就是 0<br /><br />否则等到最近的定时器到期</p></span></div><div><span><p>selector.select(等待时长)<br /><br />Linux 上就是 epoll_wait</p></span></div><div><span><p>就绪 IO 对应的回调放入就绪队列</p></span></div><div><span><p>到期定时器的回调放入就绪队列</p></span></div><div><span><p>依次执行这一轮就绪队列里的回调</p></span></div>
</div><div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>计算等待时长<br /><br />就绪队列不为空就是 0<br /><br />否则等到最近的定时器到期</p></span></div><div><span><p>selector.select(等待时长)<br /><br />Linux 上就是 epoll_wait</p></span></div><div><span><p>就绪 IO 对应的回调放入就绪队列</p></span></div><div><span><p>到期定时器的回调放入就绪队列</p></span></div><div><span><p>依次执行这一轮就绪队列里的回调</p></span></div>
</div></div></div><p>协程是怎么挂进这个循环的？<code>asyncio.run</code> 或 <code>gather</code> 会把协程包装成 <strong>Task</strong>。Task 推进协程执行，直到遇到 <code>await</code> 一个还没完成的操作，比如等数据库返回数据：</p><ol>
<li>底层把这个连接注册到 selector 上，表示”可读时通知我”；</li>
<li>协程在 <code>await</code> 处暂停，Task 把”恢复这个协程”登记成这个操作完成时的回调，然后把控制权还给事件循环；</li>
<li>数据到达后，selector 返回这个连接，对应的回调读出数据、标记操作完成，再把”恢复协程”放进就绪队列；</li>
<li>下一次轮到它时，协程从 <code>await</code> 的下一行继续执行。</li>
</ol><p>用一个小例子看执行顺序：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>import</span><span> asyncio</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>async</span><span> </span><span>def</span><span> </span><span>worker</span><span>(</span><span>name</span><span>,</span><span><span> </span><span>delay</span></span><span>):</span></div></div><div><div><div>4</div></div><div><span>    </span><span>print</span><span>(</span><span>f</span><span>"</span><span>{</span><span>name</span><span>}</span><span> 开始"</span><span>)</span></div></div><div><div><div>5</div></div><div><span>    </span><span>await</span><span><span> asyncio.</span><span>sleep</span><span>(delay)   </span></span><span># 挂起：登记一个定时器，把控制权还给事件循环</span></div></div><div><div><div>6</div></div><div><span>    </span><span>print</span><span>(</span><span>f</span><span>"</span><span>{</span><span>name</span><span>}</span><span> 结束"</span><span>)</span></div></div><div><div><div>7</div></div><div>
</div></div><div><div><div>8</div></div><div><span>async</span><span> </span><span>def</span><span> </span><span>main</span><span>():</span></div></div><div><div><div>9</div></div><div><span><span>    </span></span><span>loop </span><span>=</span><span> asyncio.</span><span>get_running_loop</span><span>()</span></div></div><div><div><div>10</div></div><div><span><span>    </span></span><span>loop.</span><span>call_soon</span><span>(</span><span>lambda</span><span>: </span><span>print</span><span>(</span><span>"call_soon 回调"</span><span>))</span></div></div><div><div><div>11</div></div><div><span>    </span><span>await</span><span><span> asyncio.</span><span>gather</span><span>(</span><span>worker</span><span>(</span></span><span>"A"</span><span>, </span><span>0.2</span><span><span>), </span><span>worker</span><span>(</span></span><span>"B"</span><span>, </span><span>0.1</span><span>))</span></div></div><div><div><div>12</div></div><div>
</div></div><div><div><div>13</div></div><div><span><span>asyncio.</span><span>run</span><span>(</span><span>main</span><span>())</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>输出：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>call_soon 回调</span></div></div><div><div><div>2</div></div><div><span>A 开始</span></div></div><div><div><div>3</div></div><div><span>B 开始</span></div></div><div><div><div>4</div></div><div><span>B 结束</span></div></div><div><div><div>5</div></div><div><span>A 结束</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>call_soon</code> 的回调先进了就绪队列，<code>gather</code> 创建的两个 Task 排在它后面，所以按顺序先打印”call_soon 回调”，再分别开始 A 和 B。两个协程遇到 <code>sleep</code> 都挂起了，事件循环在 epoll 上等到最近的定时器（0.1 秒）到期，先恢复 B，再等到 0.2 秒恢复 A。</p><p>和 JavaScript 比，asyncio 的规则更简单：<strong>没有宏任务和微任务之分</strong>，所有马上要执行的回调都在同一个就绪队列里排队。医院的类比里，就是医生每一轮先看看下一个复诊病人还要多久，最多等那么久；检验科的结果和到点的复诊一起放进候诊区，医生再按顺序一个个看。</p></section><section><h3>三者放在一起<a href="#三者放在一起"><span>#</span></a></h3>

<table><thead><tr><th></th><th>浏览器</th><th>Node.js</th><th>Python asyncio</th></tr></thead><tbody><tr><td>执行代码的线程</td><td>一个主线程</td><td>一个主线程</td><td>一个线程</td></tr><tr><td>谁负责”等”</td><td>浏览器的其他线程</td><td>epoll + 文件 IO 线程池</td><td>epoll（或 kqueue 等）</td></tr><tr><td>队列</td><td>宏任务、微任务</td><td>分阶段的队列、微任务、nextTick</td><td>就绪队列、定时器堆</td></tr><tr><td>什么时候切换</td><td>一个任务或回调执行完</td><td>一个回调执行完</td><td>协程执行到 <code>await</code></td></tr><tr><td>长时间计算的后果</td><td>页面卡住</td><td>整个服务卡住</td><td>所有协程卡住</td></tr></tbody></table><p>三者共同的限制也一样：<strong>只有一个医生</strong>。它们解决的是”等”的问题，不是”算”的问题。这也是我在百度最后用了多进程 + 协程的原因：协程负责让每个进程在等 Palo 的时候不闲着，而单个事件循环只能用满一个核的上限，要靠多开几个”诊室”（进程）来突破。</p><p>所以在百度遇到这个问题时，与其说是学了一个新技术，不如说是把以前在前端想明白的东西搬到了后端：<strong>让医生一直在看病人，而不是在门口等结果。</strong></p></section></section>]]></content>
    </entry>
    <entry>
      <id>https://tracerzzz.cn/posts/cdn-new-customer-pricing/</id>
      <title type="text">CDN 新客户接入前的价格测算：用波形拟合算出最低成本价</title>
      <published>2026-10-25T13:00:00.000Z</published>
      <updated>2026-10-25T13:00:00.000Z</updated>
      <author><name>tracerZzz</name></author>
      <link rel="alternate" href="https://tracerzzz.cn/posts/cdn-new-customer-pricing/"/>
      <summary type="text">火山 CDN 经营分析平台上的价格测算功能：销售拿到一个新客户的带宽打点，平台把它和大盘拟合，判断客户是否错峰、算出边际超卖倍数，再结合资源诉求的加权成本和包头系数，给出不同计费方式下的最低成本价。这篇讲它的输入、计算过程、示例和后续的优化方向。</summary>
      <content type="html"><![CDATA[<p>接入一个新的 CDN 客户之前，销售最常问的问题是：<strong>这个客户最低能报多少钱？</strong></p>
<p>报高了，客户被竞争对手抢走；报低了，接进来就亏钱。而 CDN 的成本又很特殊：同样大小的两个客户，因为高峰时间不同，给厂商带来的成本可能差好几倍（见<a href="/posts/cdn-off-peak-and-overselling/">《CDN 的错峰与超卖》</a>）。光靠经验拍价格，很难准。</p>
<p>火山 CDN 的经营分析平台上有一个<strong>价格测算</strong>功能，就是用来回答这个问题的。这篇介绍它的思路。</p>
<blockquote><p>文中的数据都是模拟的，成本用相对指数表示，不代表真实价格。</p></blockquote>
<section><h2>一、输入<a href="#一输入"><span>#</span></a></h2><p>使用者（一般是销售或运营）需要提供三类输入：</p><p><strong>1. 客户的带宽打点。</strong> 5 分钟粒度的带宽数据，要求：</p><ul>
<li><strong>至少 7 天</strong>：覆盖一个完整的周周期，把工作日和周末的差异都包含进去；</li>
<li><strong>最能体现客户业务特性的稳定波形</strong>：不能拿一个做活动的特殊时段来测，否则拟合出来的结果不代表客户的常态。</li>
</ul><p>这两点是测算结果可信的前提，平台对使用者专门做过宣贯。错峰效果完全取决于客户的波形，输入的波形不对，后面算得再精确也没有意义。</p><p><strong>2. 资源诉求。</strong> 客户对接入资源的要求，比如全部要联通、全部要电信，或者可以接受非 80 端口的低价节点、PCDN 节点等。不同资源的成本差别很大。</p><p><strong>3. 包头系数。</strong> 后面会讲到，它是留给销售的议价空间，测算时可以调整。</p></section>
<section><h2>二、测算过程<a href="#二测算过程"><span>#</span></a></h2><div><div><div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>客户打点（≥ 7 天）</p></span></div><div><span><p>与大盘波形拟合，得到新大盘</p></span></div><div><span><p>大盘历史打点</p></span></div><div><span><p>大盘 95 增量 ΔB</p></span></div><div><span><p>客户自身的计费用量<br /><br />95 / 日峰月均 / 流量……</p></span></div><div><span><p>错峰带宽 = 客户 95 − ΔB<br /><br />是否为正？</p></span></div><div><span><p>边际超卖倍数 k</p></span></div><div><span><p>资源诉求</p></span></div><div><span><p>加权资源成本 C</p></span></div><div><span><p>增量成本 = ΔB × C</p></span></div><div><span><p>各计费方式的最低成本价</p></span></div><div><span><p>包头系数 h</p></span></div>
</div><div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>客户打点（≥ 7 天）</p></span></div><div><span><p>与大盘波形拟合，得到新大盘</p></span></div><div><span><p>大盘历史打点</p></span></div><div><span><p>大盘 95 增量 ΔB</p></span></div><div><span><p>客户自身的计费用量<br /><br />95 / 日峰月均 / 流量……</p></span></div><div><span><p>错峰带宽 = 客户 95 − ΔB<br /><br />是否为正？</p></span></div><div><span><p>边际超卖倍数 k</p></span></div><div><span><p>资源诉求</p></span></div><div><span><p>加权资源成本 C</p></span></div><div><span><p>增量成本 = ΔB × C</p></span></div><div><span><p>各计费方式的最低成本价</p></span></div><div><span><p>包头系数 h</p></span></div>
</div></div></div><section><h3>1. 波形拟合：算出大盘 95 的增量<a href="#1-波形拟合算出大盘-95-的增量"><span>#</span></a></h3><p>把客户的打点和大盘同一时段的打点逐点相加，得到「接入后的新大盘」，再分别计算新、旧大盘的 95，两者之差就是这个客户带来的<strong>大盘 95 增量 ΔB</strong>。</p></section><section><h3>2. 判断错峰<a href="#2-判断错峰"><span>#</span></a></h3><span><span><span>错峰带宽=B客户−ΔB,k=B客户ΔB\text{错峰带宽} = B_{\text{客户}} - \Delta B, \qquad k = \frac{B_{\text{客户}}}{\Delta B}</span><span><span><span></span><span><span>错峰带宽</span></span><span></span><span>=</span><span></span></span><span><span></span><span><span>B</span><span><span><span><span><span><span></span><span><span><span><span>客户</span></span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span><span></span><span>−</span><span></span></span><span><span></span><span>Δ</span><span>B</span><span>,</span><span></span><span></span><span>k</span><span></span><span>=</span><span></span></span><span><span></span><span><span></span><span><span><span><span><span><span></span><span><span>Δ</span><span>B</span></span></span><span><span></span><span></span></span><span><span></span><span><span><span>B</span><span><span><span><span><span><span></span><span><span><span><span>客户</span></span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span><span></span></span></span></span></span></span><p>B_客户 是客户自己的 95。错峰带宽为正（k &gt; 1），说明客户的高峰落在大盘的低谷，是错峰客户；为零或为负，说明和大盘同峰。</p><p><strong>这是测算里最重要的结论。</strong> 价格是参考，错峰的正负才是判断这个客户「好不好」的依据。</p></section><section><h3>3. 加权资源成本<a href="#3-加权资源成本"><span>#</span></a></h3><p>客户的资源诉求决定了成本单价。把各类资源的成本按客户的流量占比加权平均：</p><span><span><span>C=∑iwi⋅ciC = \sum_i w_i \cdot c_i</span><span><span><span></span><span>C</span><span></span><span>=</span><span></span></span><span><span></span><span><span><span><span><span><span></span><span><span>i</span></span></span><span><span></span><span><span>∑</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span><span></span><span><span>w</span><span><span><span><span><span><span></span><span><span>i</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span><span></span><span>⋅</span><span></span></span><span><span></span><span><span>c</span><span><span><span><span><span><span></span><span><span>i</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span></span></span></span></span><p>比如：</p>

<table><thead><tr><th>资源</th><th>占比 wᵢ</th><th>成本指数 cᵢ</th></tr></thead><tbody><tr><td>联通</td><td>50%</td><td>1.0</td></tr><tr><td>电信</td><td>30%</td><td>1.2</td></tr><tr><td>非 80 端口节点</td><td>20%</td><td>0.6</td></tr><tr><td><strong>加权成本 C</strong></td><td></td><td><strong>0.98</strong></td></tr></tbody></table><p>这里的加权，本来就是为了把分运营商、分资源的细分成本价模糊成一个数，和「一个大盘」的拟合方式相匹配。</p></section><section><h3>4. 最低成本价<a href="#4-最低成本价"><span>#</span></a></h3><p>接入这个客户，厂商要多付的成本是 <strong>ΔB × C</strong>。向客户收费时，计费用量要乘以<strong>包头系数 h</strong>。最低成本价的原则是：<strong>乘上包头系数之后的收入，不能低于增加的成本</strong>。</p><span><span><span>Pmin⁡×h×B客户 ≥ ΔB×C⟹Pmin⁡=Ck×hP_{\min} \times h \times B_{\text{客户}} \ \ge\ \Delta B \times C
\quad\Longrightarrow\quad
P_{\min} = \frac{C}{k \times h}</span><span><span><span></span><span><span>P</span><span><span><span><span><span><span></span><span><span><span><span>m</span><span>i</span><span>n</span></span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span><span></span><span>×</span><span></span></span><span><span></span><span>h</span><span></span><span>×</span><span></span></span><span><span></span><span><span>B</span><span><span><span><span><span><span></span><span><span><span><span>客户</span></span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span><span> </span><span></span><span>≥</span><span> </span><span></span></span><span><span></span><span>Δ</span><span>B</span><span></span><span>×</span><span></span></span><span><span></span><span>C</span><span></span><span></span><span>⟹</span><span></span><span></span></span><span><span></span><span><span>P</span><span><span><span><span><span><span></span><span><span><span><span>m</span><span>i</span><span>n</span></span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span><span></span><span>=</span><span></span></span><span><span></span><span><span></span><span><span><span><span><span><span></span><span><span>k</span><span></span><span>×</span><span></span><span>h</span></span></span><span><span></span><span></span></span><span><span></span><span><span>C</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span><span></span></span></span></span></span></span><p>这个式子很好地说明了各个因素的作用：</p><ul>
<li><strong>k 越大（越错峰）</strong>，最低价越低；</li>
<li><strong>资源越贵（C 越大）</strong>，最低价越高；</li>
<li><strong>包头系数 h 越大</strong>，同样的单价能收回更多，报价就可以更低。h 在测算时是一个可调的输入，用来给销售留出议价空间。</li>
</ul></section><section><h3>5. 不同计费方式的最低价<a href="#5-不同计费方式的最低价"><span>#</span></a></h3><p>客户可能希望按 95、按日峰月均或者按流量计费。成本侧的增量都是同一个 ΔB × C，不同的只是客户在各计费方式下的计费用量 Q。平台会根据上传的打点，分别算出各计费方式的用量，再分别给出最低价：</p><span><span><span>Pmin⁡, m=ΔB×CQm×hP_{\min,\,m} = \frac{\Delta B \times C}{Q_m \times h}</span><span><span><span></span><span><span>P</span><span><span><span><span><span><span></span><span><span><span><span>m</span><span>i</span><span>n</span></span><span>,</span><span></span><span>m</span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span><span></span><span>=</span><span></span></span><span><span></span><span><span></span><span><span><span><span><span><span></span><span><span><span>Q</span><span><span><span><span><span><span></span><span><span>m</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span><span></span><span>×</span><span></span><span>h</span></span></span><span><span></span><span></span></span><span><span></span><span><span>Δ</span><span>B</span><span></span><span>×</span><span></span><span>C</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span><span></span></span></span></span></span></span><p>m 代表计费方式。销售可以同时看到「按 95 最低报多少、按流量最低报多少」，按客户的偏好去谈。</p></section></section>
<section><h2>三、示例<a href="#三示例"><span>#</span></a></h2><p>用 7 天的模拟数据，测算两个规模相近的客户：客户甲的高峰在上午 10 点，客户乙的高峰在晚上 21 点，和大盘一样。大盘的 95 约为 679 Mbps，资源诉求都按上面的表格（C = 0.98）。</p><p><strong>错峰判断：</strong></p>

<table><thead><tr><th></th><th>客户自己的 95</th><th>大盘 95 增量 ΔB</th><th>错峰带宽</th><th>边际超卖倍数 k</th><th>结论</th></tr></thead><tbody><tr><td>客户甲</td><td>208.5</td><td>22.1</td><td><strong>+186.4</strong></td><td>9.42</td><td>错峰</td></tr><tr><td>客户乙</td><td>207.0</td><td>208.8</td><td><strong>−1.9</strong></td><td>0.99</td><td>同峰</td></tr></tbody></table><p><strong>最低成本价</strong>（成本指数；95 和日峰月均为每 Mbps·月，流量为每 TB）：</p>

<table><thead><tr><th>计费方式</th><th>客户甲（h = 1.0）</th><th>客户甲（h = 1.1）</th><th>客户乙（h = 1.0）</th><th>客户乙（h = 1.1）</th></tr></thead><tbody><tr><td>95 峰值</td><td>0.104</td><td>0.095</td><td>0.989</td><td>0.899</td></tr><tr><td>日峰月均</td><td>0.096</td><td>0.087</td><td>0.896</td><td>0.815</td></tr><tr><td>流量</td><td>1.18</td><td>1.07</td><td>11.46</td><td>10.42</td></tr></tbody></table><p>两个客户规模几乎一样，资源诉求也一样，但客户甲的最低成本价只有客户乙的十分之一左右。如果按经验给两个客户报同一个价格，要么错失客户甲，要么接入客户乙就亏钱。</p></section>
<section><h2>四、回头看：可以继续优化的地方<a href="#四回头看可以继续优化的地方"><span>#</span></a></h2><p>先看优化后的测算流程，可以和第二节的流程图对照。虚线框是新增或改变的步骤：</p><div><div><div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>客户打点（≥ 7 天）</p></span></div><div><span><p>按资源占比拆分客户波形</p></span></div><div><span><p>资源诉求（各资源占比）</p></span></div><div><span><p>联通部分</p></span></div><div><span><p>电信部分</p></span></div><div><span><p>非 80 / PCDN 部分</p></span></div><div><span><p>与联通大盘拟合</p></span></div><div><span><p>与电信大盘拟合</p></span></div><div><span><p>与非 80 / PCDN 大盘拟合</p></span></div><div><span><p>ΔB₁，错峰判断</p></span></div><div><span><p>ΔB₂，错峰判断</p></span></div><div><span><p>ΔB₃，错峰判断</p></span></div><div><span><p>增量保护：ΔBᵢ 不低于 Bᵢ ÷ k_max</p></span></div><div><span><p>增量成本 = Σ ΔBᵢ × 各池成本价 cᵢ + 固定成本分摊</p></span></div><div><span><p>客户自身的计费用量<br /><br />95 / 日峰月均 / 流量……</p></span></div><div><span><p>各计费方式的最低成本价</p></span></div><div><span><p>包头系数 h</p></span></div>
</div><div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>客户打点（≥ 7 天）</p></span></div><div><span><p>按资源占比拆分客户波形</p></span></div><div><span><p>资源诉求（各资源占比）</p></span></div><div><span><p>联通部分</p></span></div><div><span><p>电信部分</p></span></div><div><span><p>非 80 / PCDN 部分</p></span></div><div><span><p>与联通大盘拟合</p></span></div><div><span><p>与电信大盘拟合</p></span></div><div><span><p>与非 80 / PCDN 大盘拟合</p></span></div><div><span><p>ΔB₁，错峰判断</p></span></div><div><span><p>ΔB₂，错峰判断</p></span></div><div><span><p>ΔB₃，错峰判断</p></span></div><div><span><p>增量保护：ΔBᵢ 不低于 Bᵢ ÷ k_max</p></span></div><div><span><p>增量成本 = Σ ΔBᵢ × 各池成本价 cᵢ + 固定成本分摊</p></span></div><div><span><p>客户自身的计费用量<br /><br />95 / 日峰月均 / 流量……</p></span></div><div><span><p>各计费方式的最低成本价</p></span></div><div><span><p>包头系数 h</p></span></div>
</div></div></div><p>和原来相比，主要有两处变化：一个大盘变成了按资源池划分的多个大盘；算出增量之后、计算成本之前，多了一道保护。下面分别说。</p><p><strong>1. 分资源池拟合。</strong> 当时是把所有资源放在一个大盘里拟合，再用加权成本。但实际上，上游往往是按运营商、按资源池分别结算 95 的，联通的大盘和电信的大盘高峰时间也未必一样。更精确的做法是<strong>把每类资源当成一个独立的大盘</strong>：按客户的资源占比，把客户的波形拆到各个大盘上，分别拟合、分别算增量，每个大盘用自己的成本价：</p><span><span><span>Pmin⁡=∑iΔBi⋅cih×B客户P_{\min} = \frac{\sum_i \Delta B_i \cdot c_i}{h \times B_{\text{客户}}}</span><span><span><span></span><span><span>P</span><span><span><span><span><span><span></span><span><span><span><span>m</span><span>i</span><span>n</span></span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span><span></span><span>=</span><span></span></span><span><span></span><span><span></span><span><span><span><span><span><span></span><span><span>h</span><span></span><span>×</span><span></span><span><span>B</span><span><span><span><span><span><span></span><span><span><span><span>客户</span></span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span></span></span><span><span></span><span></span></span><span><span></span><span><span><span>∑</span><span><span><span><span><span><span></span><span><span>i</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span><span></span><span>Δ</span><span><span>B</span><span><span><span><span><span><span></span><span><span>i</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span><span></span><span>⋅</span><span></span><span><span>c</span><span><span><span><span><span><span></span><span><span>i</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span><span></span></span></span></span></span></span><p>这时资源占比就不再用于加权成本，而是用于拆分客户的波形；每个资源池也有各自的错峰情况。</p><p><strong>分池之后，怎么给出一个综合的错峰结论？</strong> 一个客户可能在联通很错峰，在电信却和大盘同峰。直接把各池的数字相加不合适，因为各池的成本差别很大：在贵的池子里同峰，比在便宜的池子里同峰代价高得多。所以综合指标应该<strong>按成本加权</strong>：</p><span><span><span>k综合=∑iBi⋅ci∑iΔBi⋅ci,综合错峰成本=∑i(Bi−ΔBi)⋅cik_{\text{综合}} = \frac{\sum_i B_i \cdot c_i}{\sum_i \Delta B_i \cdot c_i}, \qquad \text{综合错峰成本} = \sum_i (B_i - \Delta B_i) \cdot c_i</span><span><span><span></span><span><span>k</span><span><span><span><span><span><span></span><span><span><span><span>综合</span></span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span><span></span><span>=</span><span></span></span><span><span></span><span><span></span><span><span><span><span><span><span></span><span><span><span>∑</span><span><span><span><span><span><span></span><span><span>i</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span><span></span><span>Δ</span><span><span>B</span><span><span><span><span><span><span></span><span><span>i</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span><span></span><span>⋅</span><span></span><span><span>c</span><span><span><span><span><span><span></span><span><span>i</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span></span></span><span><span></span><span></span></span><span><span></span><span><span><span>∑</span><span><span><span><span><span><span></span><span><span>i</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span><span></span><span><span>B</span><span><span><span><span><span><span></span><span><span>i</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span><span></span><span>⋅</span><span></span><span><span>c</span><span><span><span><span><span><span></span><span><span>i</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span><span></span></span><span>,</span><span></span><span></span><span><span>综合错峰成本</span></span><span></span><span>=</span><span></span></span><span><span></span><span><span><span><span><span><span></span><span><span>i</span></span></span><span><span></span><span><span>∑</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span><span>(</span><span><span>B</span><span><span><span><span><span><span></span><span><span>i</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span><span></span><span>−</span><span></span></span><span><span></span><span>Δ</span><span><span>B</span><span><span><span><span><span><span></span><span><span>i</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span><span>)</span><span></span><span>⋅</span><span></span></span><span><span></span><span><span>c</span><span><span><span><span><span><span></span><span><span>i</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span></span></span></span></span><p>分子是「如果客户在每个池子都和大盘完全同峰，厂商要多付的成本」，分母是客户实际带来的增量成本，两者之比就是综合的边际超卖倍数；综合错峰成本为正，就是整体错峰。</p><p>这个定义代回最低价公式，结构和原来完全一样：</p><span><span><span>Pmin⁡=C加权k综合×h,C加权=∑iBiciB客户P_{\min} = \frac{C_{\text{加权}}}{k_{\text{综合}} \times h}, \qquad C_{\text{加权}} = \frac{\sum_i B_i c_i}{B_{\text{客户}}}</span><span><span><span></span><span><span>P</span><span><span><span><span><span><span></span><span><span><span><span>m</span><span>i</span><span>n</span></span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span><span></span><span>=</span><span></span></span><span><span></span><span><span></span><span><span><span><span><span><span></span><span><span><span>k</span><span><span><span><span><span><span></span><span><span><span><span>综合</span></span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span><span></span><span>×</span><span></span><span>h</span></span></span><span><span></span><span></span></span><span><span></span><span><span><span>C</span><span><span><span><span><span><span></span><span><span><span><span>加权</span></span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span><span></span></span><span>,</span><span></span><span></span><span><span>C</span><span><span><span><span><span><span></span><span><span><span><span>加权</span></span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span><span></span><span>=</span><span></span></span><span><span></span><span><span></span><span><span><span><span><span><span></span><span><span><span>B</span><span><span><span><span><span><span></span><span><span><span><span>客户</span></span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span></span></span><span><span></span><span></span></span><span><span></span><span><span><span>∑</span><span><span><span><span><span><span></span><span><span>i</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span><span></span><span><span>B</span><span><span><span><span><span><span></span><span><span>i</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span><span><span>c</span><span><span><span><span><span><span></span><span><span>i</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span><span></span></span></span></span></span></span><p>也就是说，原来「一个大盘」的公式，只是分池版本在只有一个池时的特例，平台的输出格式和使用者的理解方式都不用变。</p><p>看一个示意的例子：</p>

<table><thead><tr><th>资源池</th><th>成本 cᵢ</th><th>客户计费带宽 Bᵢ</th><th>增量 ΔBᵢ</th><th>分池 k</th><th>分池结论</th></tr></thead><tbody><tr><td>联通</td><td>1.0</td><td>100</td><td>10</td><td>10</td><td>错峰</td></tr><tr><td>电信</td><td>1.2</td><td>60</td><td>60</td><td>1.0</td><td>同峰</td></tr><tr><td>非 80</td><td>0.6</td><td>40</td><td>5</td><td>8</td><td>错峰</td></tr></tbody></table><ul>
<li>简单相加：k = 200 ÷ 75 ≈ 2.67；</li>
<li>按成本加权：k_综合 = (100 + 72 + 24) ÷ (10 + 72 + 3) = 196 ÷ 85 ≈ <strong>2.31</strong>。</li>
</ul><p>成本加权的结果更低，因为客户恰好在最贵的电信池同峰。简单相加会高估他的错峰程度，报价就可能偏低。</p><p>但综合指标会把信息压缩掉，而<strong>分池明细里往往藏着可以操作的机会</strong>。上面这个客户，电信同峰、联通和非 80 错峰：</p><ul>
<li><strong>报价</strong>：用综合 k 定最低价；</li>
<li><strong>调度</strong>：和客户沟通，把一部分电信流量调到联通或非 80 节点，他的综合 k 会明显上升，最低价随之下降，厂商和客户都受益；</li>
<li><strong>资源规划</strong>：如果很多新客户都在电信池同峰，说明电信大盘的高峰是瓶颈，需要优先扩容，或者优先拓展在电信池错峰的客户。</li>
</ul><p>所以分池之后，平台的输出应该是<strong>一个综合结论（是否错峰、综合 k、最低价）加一张分池明细表</strong>：综合指标用来定价，分池明细用来做优化。</p><p><strong>2. 对极端的错峰结果做保护。</strong> 客户甲的 k 高达 9.4，按公式算出的最低价只有资源成本的十分之一左右。当时价格只作为参考，靠「至少 7 天、稳定波形」的输入要求来保证测算可信。如果要把测算结果直接用于报价，还可以给 k 设一个上限，并加上机房、运维等固定成本的分摊，避免因为客户业务形态变化或大盘变化导致报价过低。在分资源池的版本里，给 k 设上限等价于给每个池子的增量设一个下限：ΔBᵢ 不低于客户在这个池子的计费带宽 Bᵢ 除以 k_max，也就是图中的「增量保护」。</p></section>
<section><h2>五、总结<a href="#五总结"><span>#</span></a></h2><p>价格测算把几件事串在了一起：</p><ul>
<li><strong>95 计费</strong>决定了成本由大盘的峰值决定；</li>
<li><strong>错峰与超卖</strong>解释了为什么同样大小的客户成本可以差很多倍；</li>
<li><strong>波形拟合</strong>把这个差异量化成了一个可以算的数；</li>
<li><strong>资源成本和包头系数</strong>把它落到了具体的价格上。</li>
</ul><p>最终，销售拿到的不只是一个价格，而是一个判断：这个客户是错峰还是同峰，最低能报到多少，议价空间有多大。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://tracerzzz.cn/posts/dst-beefalo-ternary-diagram/</id>
      <title type="text">饥荒驯牛与三元相图：把游戏设定搬进数学</title>
      <published>2026-10-25T13:00:00.000Z</published>
      <updated>2026-10-25T13:00:00.000Z</updated>
      <author><name>tracerZzz</name></author>
      <link rel="alternate" href="https://tracerzzz.cn/posts/dst-beefalo-ternary-diagram/"/>
      <summary type="text">饥荒联机版里，牛驯好后是什么形态，由战牛、行牛、肥牛三种驯势的占比决定。三个占比加起来是 100%，正好对应正三角形里任意一点到三边的距离之和恒等于高，顺着这个想法就到了三元相图，普通牛的区域是中间那个小三角。这篇记录这个发现过程，以及按游戏代码算出来的驯普通牛方法。</summary>
      <content type="html"><![CDATA[<p>前段时间玩《饥荒联机版》，想驯一头牛。驯牛要十几二十天，期间牛会根据你怎么对待它，慢慢偏向战牛、行牛或肥牛三种形态之一，三种都不偏就是普通牛。</p>
<p>我先去查了 wiki 上的驯牛规则。读完之后，脑子里隐约有个三角形。这篇就是顺着这个三角形想下去的过程，后半部分是按游戏代码算出来的驯普通牛方法。</p>
<section><h2>一、wiki 上的规则<a href="#一wiki-上的规则"><span>#</span></a></h2><p>驯化期间，牛身上记着三个数，叫驯势点数，下文分别用三个字母表示：</p><ul>
<li><strong>O</strong>：战牛（Ornery）驯势点数，骑在牛上打架时涨；</li>
<li><strong>R</strong>：行牛（Rider）驯势点数，骑着牛时涨；</li>
<li><strong>P</strong>：肥牛（Pudgy）驯势点数，牛吃得饱时涨。</li>
</ul><p>驯化度到 100% 后再喂一次，形态就锁定了。判定只看三项的<strong>占比</strong>：</p><ul>
<li>哪一项占到总数的一半，就是哪种牛；</li>
<li>都不到一半，就是普通牛。</li>
</ul><p>下文用小写字母表示占比：</p><span><span><span>o=OO+R+P,r=RO+R+P,p=PO+R+P,o+r+p=1o = \frac{O}{O+R+P},\quad r = \frac{R}{O+R+P},\quad p = \frac{P}{O+R+P},\qquad o + r + p = 1</span><span><span><span></span><span>o</span><span></span><span>=</span><span></span></span><span><span></span><span><span></span><span><span><span><span><span><span></span><span><span>O</span><span></span><span>+</span><span></span><span>R</span><span></span><span>+</span><span></span><span>P</span></span></span><span><span></span><span></span></span><span><span></span><span><span>O</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span><span></span></span><span>,</span><span></span><span></span><span>r</span><span></span><span>=</span><span></span></span><span><span></span><span><span></span><span><span><span><span><span><span></span><span><span>O</span><span></span><span>+</span><span></span><span>R</span><span></span><span>+</span><span></span><span>P</span></span></span><span><span></span><span></span></span><span><span></span><span><span>R</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span><span></span></span><span>,</span><span></span><span></span><span>p</span><span></span><span>=</span><span></span></span><span><span></span><span><span></span><span><span><span><span><span><span></span><span><span>O</span><span></span><span>+</span><span></span><span>R</span><span></span><span>+</span><span></span><span>P</span></span></span><span><span></span><span></span></span><span><span></span><span><span>P</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span><span></span></span><span>,</span><span></span><span></span><span>o</span><span></span><span>+</span><span></span></span><span><span></span><span>r</span><span></span><span>+</span><span></span></span><span><span></span><span>p</span><span></span><span>=</span><span></span></span><span><span></span><span>1</span></span></span></span></span><p>比如 <span><span>O=1, R=2, P=1O = 1,\ R = 2,\ P = 1</span><span><span><span></span><span>O</span><span></span><span>=</span><span></span></span><span><span></span><span>1</span><span>,</span><span> </span><span></span><span>R</span><span></span><span>=</span><span></span></span><span><span></span><span>2</span><span>,</span><span> </span><span></span><span>P</span><span></span><span>=</span><span></span></span><span><span></span><span>1</span></span></span></span>，占比就是 <span><span>o=25%, r=50%, p=25%o = 25\%,\ r = 50\%,\ p = 25\%</span><span><span><span></span><span>o</span><span></span><span>=</span><span></span></span><span><span></span><span>25%</span><span>,</span><span> </span><span></span><span>r</span><span></span><span>=</span><span></span></span><span><span></span><span>50%</span><span>,</span><span> </span><span></span><span>p</span><span></span><span>=</span><span></span></span><span><span></span><span>25%</span></span></span></span>。行牛刚好占一半，这头牛驯完是行牛。</p></section>
<section><h2>二、从三角形到三元相图<a href="#二从三角形到三元相图"><span>#</span></a></h2><p>读完规则，我第一反应是”有点像三角形”：三个东西互相拉扯，像三角形的三个角。但具体哪里像，一开始说不上来。</p><p>后来反复琢磨，抓住的是 <span><span>o+r+p=1o + r + p = 1</span><span><span><span></span><span>o</span><span></span><span>+</span><span></span></span><span><span></span><span>r</span><span></span><span>+</span><span></span></span><span><span></span><span>p</span><span></span><span>=</span><span></span></span><span><span></span><span>1</span></span></span></span> 这一点。三个数加起来恒为 100%，这和正三角形的一个性质对得上：<strong>正三角形内任意一点，到三条边的距离之和恒等于三角形的高。</strong>（这叫维维亚尼定理，证明很简单：把这个点和三个顶点连起来，大三角形被分成三个小三角形，三块面积加起来等于总面积，底边又都一样长，所以三个高加起来就是大三角形的高。）</p><p>把高当作 100%，三角形里的每个点就对应一组 <span><span>(o,r,p)(o, r, p)</span><span><span><span></span><span>(</span><span>o</span><span>,</span><span></span><span>r</span><span>,</span><span></span><span>p</span><span>)</span></span></span></span>。约定每个占比是这个点到对应顶点<strong>对边</strong>的距离：越靠近”战牛”那个角，离它对边越远，<span><span>oo</span><span><span><span></span><span>o</span></span></span></span> 越大。</p><p></p><figure><img alt="正三角形三个顶点分别是战牛 O、行牛 R、肥牛 P，三角形内一点向三条边作垂线，长度分别是 o=20%、r=45%、p=35%，三者之和等于高，也就是 100%" loading="lazy" width="958" height="902" src="/_astro/fig0-viviani.aqtH3O5w_y84Ay.webp" /><figcaption>正三角形三个顶点分别是战牛 O、行牛 R、肥牛 P，三角形内一点向三条边作垂线，长度分别是 o=20%、r=45%、p=35%，三者之和等于高，也就是 100%</figcaption></figure><p></p><p>按这个思路一查，原来这东西早就有名字，叫<strong>三元相图</strong>（ternary diagram），化学、材料、土壤分类里常用来表示三种成分的配比。</p><p>接下来把 wiki 里那个关键的 50% 放进图里。<span><span>o=50%o = 50\%</span><span><span><span></span><span>o</span><span></span><span>=</span><span></span></span><span><span></span><span>50%</span></span></span></span> 意味着点到对边的距离是高的一半，所有这样的点连成一条和对边平行、正好在半高处的线。这条线经过另外两条边的中点，也就是三角形的中位线。</p><p>三个角各有一条这样的中位线，各自切下一个角上的小三角，那里是战牛、行牛、肥牛。三条中位线围出来的，是中间一个倒着的小三角：<strong>普通牛区</strong>。这个小三角的中心，正好是大三角形的中心，也就是 <span><span>o=r=p=13o = r = p = \frac{1}{3}</span><span><span><span></span><span>o</span><span></span><span>=</span><span></span></span><span><span></span><span>r</span><span></span><span>=</span><span></span></span><span><span></span><span>p</span><span></span><span>=</span><span></span></span><span><span></span><span><span></span><span><span><span><span><span><span></span><span><span><span>3</span></span></span></span><span><span></span><span></span></span><span><span></span><span><span><span>1</span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span><span></span></span></span></span></span> 那个点。</p><p></p><figure><img alt="三元相图：三个角上的小三角分别是行牛、战牛、肥牛区域，中间倒三角是普通牛区，中心点是 1:1；左边中点标出肥牛点数为 0 时普通牛区缩成的那一个点" loading="lazy" width="1124" height="902" src="/_astro/fig1-ternary-regions.CKU37GPd_Z1ThsCd.webp" /><figcaption>三元相图：三个角上的小三角分别是行牛、战牛、肥牛区域，中间倒三角是普通牛区，中心点是 1:1；左边中点标出肥牛点数为 0 时普通牛区缩成的那一个点</figcaption></figure><p></p><p>到这里，游戏里的一条规则就完整变成了一幅几何图。我觉得这个过程挺有意思：wiki 上写的是一句话，搬到数学里，变成了正三角形、垂线和中位线。</p></section>
<section><h2>三、四块一样大，但均衡很难<a href="#三四块一样大但均衡很难"><span>#</span></a></h2><p>三条中位线把大三角形分成了四个全等的小三角，<strong>四种牛的区域面积一模一样</strong>，各占四分之一。如果在三角形里随手撒一个点，落进普通牛区的机会和落进任何一个角一样大。</p><p>可是放到生活里，这种均衡并不多见。最像的是时间分配：一天就 24 小时，工作、家庭、自己三块加起来恒等于 100%，和 <span><span>o+r+p=1o + r + p = 1</span><span><span><span></span><span>o</span><span></span><span>+</span><span></span></span><span><span></span><span>r</span><span></span><span>+</span><span></span></span><span><span></span><span>p</span><span></span><span>=</span><span></span></span><span><span></span><span>1</span></span></span></span> 是一回事。</p><ul>
<li>某一块占到一半以上，生活就偏向一个角：工作占满的人，围着家转的人，只顾自己的人。</li>
<li>三块都不过半，才是常说的”平衡”。</li>
</ul><p>图上平衡区占了四分之一，看起来不小，可现实里能长期待在中间的人并不多。我觉得原因和驯牛是一样的：</p><ul>
<li><strong>状态是攒出来的，不是选出来的。</strong> 牛驯完是什么形态，是二十天里每一次骑、每一次喂、每一次打攒出来的；生活偏向哪里，也是每一天的时间一点点攒出来的，没有哪一天专门决定它。</li>
<li><strong>不刻意就会偏。</strong> 驯牛时不管它，牛会被你习惯的玩法拉向某个角，爱跑图的得到行牛，爱打架的得到战牛。时间也一样，不主动安排，就会被最急的那件事拉走。</li>
</ul><p>所以四种牛里，普通牛看起来最普通，其实是最需要用心的那一种：三头都得照顾到。</p></section>
<section><h2>四、驯普通牛：按代码算<a href="#四驯普通牛按代码算"><span>#</span></a></h2><p>下面的数值都来自游戏脚本（<code>tuning.lua</code>、<code>prefabs/beefalo.lua</code>、<code>components/hunger.lua</code>、<code>components/domesticatable.lua</code>），换算成一天（8 分钟，480 秒）：</p>

<table><thead><tr><th>驯势</th><th>怎么涨</th><th>涨多少</th><th>一天最多</th></tr></thead><tbody><tr><td>战牛 O</td><td>骑在牛上时，你攻击一次，或牛挨打一次</td><td>每次 +0.004</td><td>看打多少下</td></tr><tr><td>行牛 R</td><td>骑在牛上</td><td>每秒 +1/2400</td><td>0.2（骑满全天）</td></tr><tr><td>肥牛 P</td><td>饥饿值高于一半时消化食物</td><td>每消化 1 点饥饿 +1/2400</td><td>0.125（全天都高于一半）</td></tr></tbody></table><p>几点说明：</p><ul>
<li>
<p><strong>战牛只算骑着的时候。</strong> 不骑时牛挨打，不涨战牛点数。</p>
</li>
<li>
<p><strong>肥牛涨得比行牛慢。</strong> <code>tuning.lua</code> 里两者系数相同，都是 <code>1/(total_day_time*5)</code>。但行牛乘的是骑行秒数；肥牛在 <code>beefalo.lua</code> 里乘的是饥饿值的变化量：</p>
<div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>if</span><span> </span><span>data</span><span>.oldpercent &gt; </span><span>0.5</span><span> </span><span>then</span></div></div><div><div><div>2</div></div><div><span>    </span><span>inst</span><span>.components.</span><span>domesticatable</span><span>:</span><span>DeltaTendency</span><span>(</span><span>TENDENCY</span><span>.PUDGY, </span><span>TUNING</span><span>.BEEFALO_PUDGY_WELLFED * -</span><span>data</span><span>.delta)</span></div></div><div><div><div>3</div></div><div><span>end</span></div></div></code></pre><div><div></div><div></div></div></figure></div>
<p><code>hunger.lua</code> 里 <code>delta = self.current - old</code>，是饥饿值的绝对点数。牛的饥饿上限 375，每天固定掉 300，所以肥牛一天最多 <span><span>300/2400=0.125300/2400 = 0.125</span><span><span><span></span><span>300/2400</span><span></span><span>=</span><span></span></span><span><span></span><span>0.125</span></span></span></span>。灰机 wiki 上写的是”每天 20%“，和代码对不上。</p>
</li>
<li>
<p><strong>“一半”包含等于。</strong> 代码里是 <code>maxtendencyval * 2 &lt; tendencysum</code> 才算普通牛，所以恰好占一半也会偏过去。另外总点数不足 0.1 时也是普通牛，不过驯满一次远超这个数。</p>
</li>
<li>
<p><strong>驯势每天会衰减 1/45（约 0.022）。</strong> 这个衰减一直在跑，不只是没人照顾的时候。</p>
</li>
</ul><section><h3>普通牛 = 三个点数能拼成一个三角形<a href="#普通牛--三个点数能拼成一个三角形"><span>#</span></a></h3><p>“没有哪一项达到总数一半”，等价于”每一项都小于另外两项之和”：</p><span><span><span>O&lt;R+P,R&lt;O+P,P&lt;O+RO &lt; R + P,\qquad R &lt; O + P,\qquad P &lt; O + R</span><span><span><span></span><span>O</span><span></span><span>&lt;</span><span></span></span><span><span></span><span>R</span><span></span><span>+</span><span></span></span><span><span></span><span>P</span><span>,</span><span></span><span></span><span>R</span><span></span><span>&lt;</span><span></span></span><span><span></span><span>O</span><span></span><span>+</span><span></span></span><span><span></span><span>P</span><span>,</span><span></span><span></span><span>P</span><span></span><span>&lt;</span><span></span></span><span><span></span><span>O</span><span></span><span>+</span><span></span></span><span><span></span><span>R</span></span></span></span></span><p>这正好是三角形不等式。所以普通牛还有一个很形象的判定：<strong>把 O、R、P 当成三根木棍的长度，能拼成一个三角形，就是普通牛。</strong></p><p>反过来，如果某一项为 0，比如完全不喂到半饱以上（<span><span>P=0P = 0</span><span><span><span></span><span>P</span><span></span><span>=</span><span></span></span><span><span></span><span>0</span></span></span></span>），剩下两根木棍无论多长都拼不出三角形。在相图上，这对应战牛和行牛之间的那条边：普通牛区在这条边上只剩一个中点，而按代码，恰好相等也会偏过去，连这一个点都不算。所以<strong>三项都得有</strong>，少一项就没有容错。</p></section><section><h3>每天打多少下<a href="#每天打多少下"><span>#</span></a></h3><p>骑多久、喂多少一般是自己先定好的，打架次数最好调。把三角形不等式换成战斗点数的范围：</p><span><span><span>∣R−P∣&lt;O&lt;R+P|R - P| &lt; O &lt; R + P</span><span><span><span></span><span>∣</span><span>R</span><span></span><span>−</span><span></span></span><span><span></span><span>P</span><span>∣</span><span></span><span>&lt;</span><span></span></span><span><span></span><span>O</span><span></span><span>&lt;</span><span></span></span><span><span></span><span>R</span><span></span><span>+</span><span></span></span><span><span></span><span>P</span></span></span></span></span><p>这个窗口宽 <span><span>2min⁡(R,P)2\min(R, P)</span><span><span><span></span><span>2</span><span></span><span>min</span><span>(</span><span>R</span><span>,</span><span></span><span>P</span><span>)</span></span></span></span>，中点是 <span><span>max⁡(R,P)\max(R, P)</span><span><span><span></span><span>max</span><span>(</span><span>R</span><span>,</span><span></span><span>P</span><span>)</span></span></span></span>。换算成每天的战斗次数（除以 0.004）：</p>

<table><thead><tr><th>每天怎么养</th><th>R/天</th><th>P/天</th><th>每天战斗次数</th><th>最稳的次数</th></tr></thead><tbody><tr><td>骑满全天，饥饿值全天高于一半</td><td>0.2</td><td>0.125</td><td>约 19～81</td><td>50</td></tr><tr><td>骑 5 分钟，饥饿值全天高于一半</td><td>0.125</td><td>0.125</td><td>约 0～62</td><td>31</td></tr><tr><td>骑满全天，饥饿值只有半天高于一半</td><td>0.2</td><td>0.0625</td><td>约 35～65</td><td>50</td></tr><tr><td>骑满全天，从不喂到一半以上</td><td>0.2</td><td>0</td><td>无解</td><td>—</td></tr></tbody></table><p>第一种情况画在图上，就是一条从”行牛—肥牛”边出发、往战牛角走的线：打得太少还在行牛区，打得太多进了战牛区，中间那段是普通牛。</p><p></p><figure><img alt="骑满全天且饥饿值全天高于一半时，随着每天战斗次数增加，点从行牛和肥牛之间的边出发，穿过普通牛区进入战牛区；进出普通牛区的位置大约是每天 19 次和 81 次，中点是每天 50 次" loading="lazy" width="974" height="902" src="/_astro/fig2-full-ride-line.D_zjYcRR_ZBlxOt.webp" /><figcaption>骑满全天且饥饿值全天高于一半时，随着每天战斗次数增加，点从行牛和肥牛之间的边出发，穿过普通牛区进入战牛区；进出普通牛区的位置大约是每天 19 次和 81 次，中点是每天 50 次</figcaption></figure><p></p><p>第二种是我最推荐的：<strong>每天骑 5 分钟</strong>（300 秒 × 1/2400 = 0.125，正好等于全天饱食的肥牛点数），让 <span><span>R=PR = P</span><span><span><span></span><span>R</span><span></span><span>=</span><span></span></span><span><span></span><span>P</span></span></span></span>，再在骑行时打 31 下左右，三项就落在三角形正中心。这时窗口是 0～62 次，下限直接降到 0。在 R 和 P 总量差不多的方案里，这是最宽的。哪天多打少打十几下，都不会出事。</p><p>算上每天约 0.022 的衰减，下限会抬高、上限会压低，各收窄约 6 下：第一种大约 24～76 次，第二种大约 6～57 次。</p><p>窗口宽度只取决于 R 和 P 中较小的那个。想要更大的容错，就补短板：肥牛少了就多喂，行牛少了就多骑。每多让 100 点饥饿在半线以上被消化（约一份蒸树枝），P 多 1/24，战斗次数的窗口两边各宽约 10 下。</p></section><section><h3>怎么让饥饿值全天高于一半<a href="#怎么让饥饿值全天高于一半"><span>#</span></a></h3><p>饥饿上限 375，一半是 187.5，每天掉 300，也就是每三分之一天（160 秒）掉 100。</p><p>我的做法是：<strong>早上把牛喂到 300，之后每三分之一天补 100。</strong> 饥饿值会在 200 到 300 之间来回，全天都高于 187.5，一天正好喂 300，大约三份蒸树枝。</p><p>喂食时有两个边界值得注意：</p><ul>
<li><strong>喂完超过 300（80%）会放屁。</strong> 这只是提醒快饱了，没有惩罚。</li>
<li><strong>饥饿值满 375 时再喂会吐。</strong> 代价是驯化度 −1%、顺从度 −30%，同时肥牛点数 +0.02。想要普通牛，要避开这一下。</li>
</ul><p>每次喂食还能 +10% 顺从度，顺带解决骑行时被甩下来的问题。</p></section><section><h3>其他<a href="#其他"><span>#</span></a></h3><ul>
<li><strong>驯化度</strong>：牛被骑着、或饥饿值大于 0 时，每 10 秒 +1/960，20 天驯满；每天刷一次毛再 +1/60，能缩到 15 天。上面的每日窗口和总天数无关，15 天、20 天都一样用。</li>
<li><strong>骑行时间</strong>：没驯完的牛，骑不了多久就会把你甩下来，重新骑上去就行，行牛点数只看骑在牛上的秒数。</li>
<li><strong>舔盐块</strong>：把牛留在舔盐块旁，驯化度和驯势都会暂停衰减，适合中途有几天顾不上的时候。</li>
</ul><blockquote><p>关于这篇文章：游戏数值来自 GitHub 上的游戏脚本镜像 <a href="https://github.com/CN-DST-DEVELOPER/scripts" target="_blank">CN-DST-DEVELOPER/scripts</a>，规则描述参考了<a href="https://dontstarve.huijiwiki.com/wiki/%E7%9A%AE%E5%BC%97%E5%A8%84%E7%89%9B" target="_blank">灰机 wiki 的皮弗娄牛页面</a>和 <a href="https://dontstarve.wiki.gg/wiki/Beefalo/DST" target="_blank">wiki.gg 的 Beefalo/DST 页面</a>，查阅于 2026 年 10 月。游戏更新后数值可能会变。</p></blockquote></section></section>]]></content>
    </entry>
    <entry>
      <id>https://tracerzzz.cn/posts/cdn-off-peak-and-overselling/</id>
      <title type="text">CDN 的错峰与超卖：为什么厂商能把同一份带宽卖好几次</title>
      <published>2026-10-25T12:00:00.000Z</published>
      <updated>2026-10-25T12:00:00.000Z</updated>
      <author><name>tracerZzz</name></author>
      <link rel="alternate" href="https://tracerzzz.cn/posts/cdn-off-peak-and-overselling/"/>
      <summary type="text">在 95 计费下，一个新客户接入后，大盘的 95 可能几乎不涨，这就是错峰客户。它让 CDN 厂商可以把同一份带宽卖给多个客户，也就是超卖。这篇用模拟数据讲清楚错峰和超卖，以及衡量它们的两个指标：单个客户的边际超卖倍数，和整个平台的超售比。</summary>
      <content type="html"><![CDATA[<p>在<a href="/posts/cdn-billing-methods/">《CDN 是怎么收费的》</a>里讲过，CDN 厂商向上游运营商付费、向大客户收费，绝大多数都按 95 峰值结算。这背后藏着一个很有意思的现象：<strong>厂商向所有客户收的带宽加起来，往往比它向上游付费的带宽多。</strong> 同一份带宽，被卖了不止一次。</p>
<p>这篇讲清楚这是怎么回事，以及在经营分析里怎么衡量它。</p>
<blockquote><p>文中的数据都是模拟的。</p></blockquote>
<section><h2>一、大盘与 95<a href="#一大盘与-95"><span>#</span></a></h2><p>厂商的所有客户共用同一批节点和出口带宽。把所有客户的带宽在每个 5 分钟点上加起来，就是<strong>大盘</strong>的带宽曲线。厂商向上游付费，看的是大盘的 95。</p><p>模拟的大盘一天有两个高峰：中午一个小高峰，晚上 21 点左右一个大高峰。一个月下来，大盘的 95 约为 <strong>679 Mbps</strong>。</p></section>
<section><h2>二、错峰：接入了新客户，成本却几乎没涨<a href="#二错峰接入了新客户成本却几乎没涨"><span>#</span></a></h2><p>现在有两个规模差不多的新客户要接入，他们自己的 95 都在 <strong>208 Mbps</strong> 左右，区别只在高峰时间：</p><ul>
<li>客户甲：业务高峰在<strong>上午 10 点</strong>；</li>
<li>客户乙：业务高峰在<strong>晚上 21 点</strong>，和大盘一样。</li>
</ul><p>分别把他们的曲线叠加到大盘上，重新算 95：</p>

<table><thead><tr><th></th><th>客户自己的 95</th><th>接入后大盘 95</th><th>大盘 95 增量</th></tr></thead><tbody><tr><td>客户甲（上午高峰）</td><td>208</td><td>702</td><td><strong>22</strong></td></tr><tr><td>客户乙（晚上高峰）</td><td>207</td><td>888</td><td><strong>208</strong></td></tr></tbody></table><p>差别非常大：</p><ul>
<li>客户甲的高峰正好落在大盘的低谷里，叠加之后，大盘的最高那些点几乎没变，95 只涨了 22；</li>
<li>客户乙的高峰和大盘的高峰叠在一起，大盘 95 实打实地涨了 208，和他自己的 95 一样多。</li>
</ul><p>客户甲就是<strong>错峰客户</strong>。对厂商来说，接入他几乎不增加上游成本。</p><p>判断一个客户是否错峰，最直接的办法是看他的计费带宽和他带来的增量之差：</p><span><span><span>错峰带宽=B客户−ΔB\text{错峰带宽} = B_{\text{客户}} - \Delta B</span><span><span><span></span><span><span>错峰带宽</span></span><span></span><span>=</span><span></span></span><span><span></span><span><span>B</span><span><span><span><span><span><span></span><span><span><span><span>客户</span></span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span><span></span><span>−</span><span></span></span><span><span></span><span>Δ</span><span>B</span></span></span></span></span><ul>
<li><strong>为正</strong>：客户自己的计费带宽大于他给大盘带来的增量，说明他错峰；</li>
<li><strong>为零或为负</strong>：客户和大盘同峰，接入之后，大盘涨得和他自己一样多，甚至更多。</li>
</ul><p>客户甲的错峰带宽是 208 − 22 = 186，明显为正；客户乙是 207 − 208 ≈ −1，没有错峰。</p></section>
<section><h2>三、超卖：同一份带宽卖了好几次<a href="#三超卖同一份带宽卖了好几次"><span>#</span></a></h2><p>厂商向客户收费，按的是<strong>客户自己的 95</strong>；向上游付费，按的是<strong>大盘的 95</strong>。</p><p>接入客户甲之后：</p><ul>
<li>厂商向客户甲收 <strong>208</strong> 的带宽费；</li>
<li>厂商向上游多付的只有 <strong>22</strong>。</li>
</ul><p>多出来的部分，就是超卖。本质上，客户甲用的是其他客户在上午闲置下来的带宽，厂商把同一份带宽在不同的时段卖给了不同的人。</p><p>这不是什么不正当的事，而是共享基础设施的天然收益，和航空公司卖机票、电信运营商卖宽带是一个道理。<strong>一家 CDN 厂商的利润，很大程度上就来自客户结构的互补</strong>：白天的办公、电商，晚上的视频、游戏，海外和国内的时差，这些都在帮厂商把同一份带宽复用起来。</p></section>
<section><h2>四、两个衡量指标<a href="#四两个衡量指标"><span>#</span></a></h2><section><h3>1. 边际超卖倍数：这个客户有多错峰<a href="#1-边际超卖倍数这个客户有多错峰"><span>#</span></a></h3><p>衡量单个客户，用他的计费带宽和他带来的增量之比：</p><span><span><span>k=B客户ΔBk = \frac{B_{\text{客户}}}{\Delta B}</span><span><span><span></span><span>k</span><span></span><span>=</span><span></span></span><span><span></span><span><span></span><span><span><span><span><span><span></span><span><span>Δ</span><span>B</span></span></span><span><span></span><span></span></span><span><span></span><span><span><span>B</span><span><span><span><span><span><span></span><span><span><span><span>客户</span></span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span><span></span></span></span></span></span></span><ul>
<li>客户甲：208 ÷ 22 ≈ <strong>9.4</strong>，每多付 1 份上游带宽，能收 9 份多的钱；</li>
<li>客户乙：207 ÷ 208 ≈ <strong>1.0</strong>，收多少就要多付多少，没有超卖空间。</li>
</ul><p>k &gt; 1 和错峰带宽为正是等价的，k 越大，越错峰。</p><p>这里有一个容易混淆的地方：「接入后大盘 95 涨了百分之几」也是一个有用的数，可以用来评估接入的影响，但它<strong>不能衡量超卖</strong>。因为它没考虑客户自己有多大：一个很大的客户让大盘涨了 10%，看起来影响不小，但如果他自己的 95 是这个增量的三倍，他其实非常错峰。所以衡量超卖，要用客户的计费带宽去比增量，而不是用增量去比原来的大盘。</p></section><section><h3>2. 超售比：整个平台卖出了多少倍<a href="#2-超售比整个平台卖出了多少倍"><span>#</span></a></h3><p>衡量整个平台，用所有客户的计费带宽之和，比上大盘的计费带宽：</p><span><span><span>超售比=∑各客户的计费带宽大盘计费带宽\text{超售比} = \frac{\sum \text{各客户的计费带宽}}{\text{大盘计费带宽}}</span><span><span><span></span><span><span>超售比</span></span><span></span><span>=</span><span></span></span><span><span></span><span><span></span><span><span><span><span><span><span></span><span><span><span>大盘计费带宽</span></span></span></span><span><span></span><span></span></span><span><span></span><span><span>∑</span><span></span><span><span>各客户的计费带宽</span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span><span></span></span></span></span></span></span><p>以接入客户甲为例，假设原来的大盘里各客户的计费带宽之和正好等于 679（实际上通常已经大于它），接入后超售比约为 (679 + 208) ÷ 702 ≈ <strong>1.26</strong>，即向客户收的带宽比向上游付的多 26%。</p><p>超售比是经营分析里的核心指标之一：它越高，说明带宽复用得越好，毛利空间越大。我在百度做 CDN 成本优化时，超售比就是日常跟踪的指标之一。</p></section></section>
<section><h2>五、错峰客户给成本分摊带来的难题<a href="#五错峰客户给成本分摊带来的难题"><span>#</span></a></h2><p>错峰带来了利润，也给<strong>成本分摊</strong>出了难题。</p><p>如果按传统的单点法分摊成本，也就是只看大盘 95 时刻那一个点，每个客户在那一刻用了多少带宽、就承担多少比例的成本，那么错峰客户在 95 时刻往往几乎没有用量，就会<strong>几乎不承担任何成本</strong>。在经营分析里，他会显得毛利极高，而同峰客户则承担了几乎全部成本。</p><p>但错峰客户并不是完全不占资源：在月内其他的高峰时段，他们多少也贡献了一些用量。单点法把这部分完全忽略了，而且结果还取决于恰好选中了哪个点。</p><p>这正是我设计「95 区间」分摊算法要解决的问题之一：在 95 值附近取一段区间，按各客户在整段区间里的用量来分摊，错峰客户也能分到和他实际占用相符的成本。详见<a href="/posts/cdn-bandwidth-cost-allocation-patent/">《我的第一项专利：用「95 区间」把 CDN 带宽成本分得更准》</a>。</p></section>
<section><h2>六、在业务上怎么用<a href="#六在业务上怎么用"><span>#</span></a></h2><p>理解了错峰和超卖，很多经营决策就有了依据：</p><ul>
<li><strong>报价</strong>：错峰客户给厂商带来的增量成本低，可以给更有竞争力的价格；同峰客户则不能低于成本线。下一篇<a href="/posts/cdn-new-customer-pricing/">《CDN 新客户接入前的价格测算》</a>会讲怎么把这个过程做成平台功能。</li>
<li><strong>拓客方向</strong>：大盘晚高峰明显，就应该多拓展白天高峰的业务；国内业务为主，就可以多接海外时区的客户。</li>
<li><strong>调度和资源规划</strong>：把可以错峰的流量（预热、批量下发）引导到低谷，本质上也是在主动制造错峰。夜间带宽减半、闲忙时计费这类计费方式，就是用价格来引导客户错峰。</li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://tracerzzz.cn/posts/cdn-billing-methods/</id>
      <title type="text">CDN 是怎么收费的：从流量、95 峰值到日峰月均，厂商和客户两边看计费</title>
      <published>2026-10-25T11:00:00.000Z</published>
      <updated>2026-10-25T11:00:00.000Z</updated>
      <author><name>tracerZzz</name></author>
      <link rel="alternate" href="https://tracerzzz.cn/posts/cdn-billing-methods/"/>
      <summary type="text">我在百度云 CDN 做过客户账单，在字节做过火山 CDN 的计费推量，也做过融合 CDN 向多家厂商采购的对账和成本分摊，厂商和采购两边都经历过。这篇整理 CDN 常见的计费方式：流量、带宽峰值、95 峰值、日峰月均、月第四峰值、夜间减半、闲忙时、请求数，以及计费区域、包头系数这些容易忽略的细节，并用一个月的模拟数据对比不同方式算出来差多少。</summary>
      <content type="html"><![CDATA[<p>在 CDN 行业做了几年，计费几乎贯穿了我所有的工作：</p>
<ul>
<li>在<strong>百度云 CDN</strong>，给头部客户出定制化账单，实现过 95 峰值、日峰月均、流量、夜间带宽减半、闲忙时等多种计费方式；</li>
<li>在<strong>字节火山 CDN</strong>，做过把客户用量推给计费中心的计量系统；</li>
<li>在<strong>字节融合 CDN</strong>，站在采购方一侧，和多家 CDN 厂商对账，再把采购成本分摊给内部业务。</li>
</ul>
<p>同一套计费规则，厂商关心的是「怎么收」，客户关心的是「怎么付得最少」。这篇把常见的计费方式整理一遍，并用一个月的模拟数据算一算，不同方式到底差多少。</p>
<blockquote><p>说明：各家厂商的具体规则（采样粒度、时段划分、取整方式等）以合同和官网为准，本文讲的是通用的思路。文中的数据都是模拟的。</p></blockquote>
<section><h2>一、两大类：按流量，还是按带宽<a href="#一两大类按流量还是按带宽"><span>#</span></a></h2><p>CDN 的计费方式，归根结底分两类：</p><ul>
<li><strong>按流量</strong>：这个月一共传了多少数据（GB、TB），用多少付多少；</li>
<li><strong>按带宽</strong>：这个月「最高」用到了多大的带宽（Mbps、Gbps），按这个高度付费。至于「最高」怎么定义，就衍生出了下面的各种方式。</li>
</ul><p>为什么会有按带宽计费？因为 CDN 厂商自己的成本，很大一部分就是按带宽算的：机房、运营商出口都要按峰值准备容量，峰值再短，资源也得提前备好。按带宽向客户收费，是把这个成本结构传导下去。</p></section>
<section><h2>二、计费的基础：5 分钟一个点<a href="#二计费的基础5-分钟一个点"><span>#</span></a></h2><p>除了纯流量计费，几乎所有带宽类的计费都基于同一份数据：<strong>每 5 分钟一个带宽点</strong>。</p><p>做法是统计这 5 分钟内的总流量，除以 300 秒，得到这 5 分钟的平均带宽：</p><span><span><span>带宽5min⁡=5 分钟内的流量（bit）300 s\text{带宽}_{5\min} = \frac{\text{5 分钟内的流量（bit）}}{300\ \text{s}}</span><span><span><span></span><span><span><span>带宽</span></span><span><span><span><span><span><span></span><span><span><span>5</span><span></span><span><span>m</span><span>i</span><span>n</span></span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span><span></span><span>=</span><span></span></span><span><span></span><span><span></span><span><span><span><span><span><span></span><span><span>300</span><span> </span><span><span>s</span></span></span></span><span><span></span><span></span></span><span><span></span><span><span><span>5 </span><span>分钟内的流量（</span><span>bit</span><span>）</span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span><span></span></span></span></span></span></span><p>一天 288 个点，30 天的月份 8640 个点，所有带宽类计费都是在这 8640 个点上做文章。我在火山 CDN 做计费推量时，带宽计费的客户就是每 5 分钟推一个点给计费中心。</p><p>为了让后面的对比有个参照，我模拟了一个客户一个月的带宽：白天有个小高峰，晚上 21 点左右是大高峰，每天的波动在 ±5% 以内；另外第 11 天晚上有一场直播活动，带宽在一个小时里冲到了平时的两倍多。</p></section>
<section><h2>三、按流量计费<a href="#三按流量计费"><span>#</span></a></h2><p>最直观的方式：把一个月所有的流量加起来，乘以单价。单价通常是<strong>阶梯价</strong>，用量越大越便宜。</p><p>模拟客户一个月的平均带宽约 472 Mbps，对应的流量约 <strong>153 TB</strong>。</p><p>带宽和流量之间有一个很好记的换算：<strong>1 Mbps 的带宽跑满一个 30 天的月，大约是 324 GB 流量</strong>。</p><span><span><span>1 Mbps×30 天=106×2,592,0008 Byte≈324 GB1\ \text{Mbps} \times 30\ \text{天} = \frac{10^6 \times 2{,}592{,}000}{8}\ \text{Byte} \approx 324\ \text{GB}</span><span><span><span></span><span>1</span><span> </span><span><span>Mbps</span></span><span></span><span>×</span><span></span></span><span><span></span><span>30</span><span> </span><span><span>天</span></span><span></span><span>=</span><span></span></span><span><span></span><span><span></span><span><span><span><span><span><span></span><span><span>8</span></span></span><span><span></span><span></span></span><span><span></span><span><span>1</span><span><span>0</span><span><span><span><span><span><span></span><span><span>6</span></span></span></span></span></span></span></span><span></span><span>×</span><span></span><span>2</span><span><span>,</span></span><span>592</span><span><span>,</span></span><span>000</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span><span></span></span><span> </span><span><span>Byte</span></span><span></span><span>≈</span><span></span></span><span><span></span><span>324</span><span> </span><span><span>GB</span></span></span></span></span></span><p>这个数后面算「该选哪种计费方式」时会用到。</p></section>
<section><h2>四、按带宽计费的几种方式<a href="#四按带宽计费的几种方式"><span>#</span></a></h2><p>用同一份模拟数据，各种带宽计费方式算出来的计费带宽如下：</p>

<table><thead><tr><th>计费方式</th><th>怎么算</th><th>计费带宽（Mbps）</th></tr></thead><tbody><tr><td>月峰值</td><td>8640 个点里的最大值</td><td>1589</td></tr><tr><td>日峰值月平均</td><td>每天取最大值，30 个值求平均</td><td>756</td></tr><tr><td>月第四峰值</td><td>每天取最大值，30 个值里排第四大的</td><td>734</td></tr><tr><td>95 峰值</td><td>8640 个点去掉最高的 5%，剩下的最大值</td><td>678</td></tr><tr><td>（参考）月平均带宽</td><td>8640 个点的平均值</td><td>472</td></tr></tbody></table><p>同一个客户、同一个月，不同算法的计费带宽能差一倍多。下面逐个说。</p><section><h3>1. 月峰值：最简单，也最「贵」<a href="#1-月峰值最简单也最贵"><span>#</span></a></h3><p>直接取整个月的最大值。问题很明显：第 11 天那一小时的直播，把计费带宽从六七百拉到了将近 1600，客户要为一个小时的尖峰付一整个月的钱。所以纯月峰值计费很少见。</p></section><section><h3>2. 95 峰值：行业最主流<a href="#2-95-峰值行业最主流"><span>#</span></a></h3><p>把 8640 个点从大到小排序，去掉最高的 5%（432 个点），第 433 个点就是 95 峰值。</p><span><span><span>95 峰值=降序排列后的第 ⌊8640×5%⌋+1=433 个点\text{95 峰值} = \text{降序排列后的第}\ \lfloor 8640 \times 5\% \rfloor + 1 = 433\ \text{个点}</span><span><span><span></span><span><span>95 </span><span>峰值</span></span><span></span><span>=</span><span></span></span><span><span></span><span><span>降序排列后的第</span></span><span> </span><span>⌊</span><span>8640</span><span></span><span>×</span><span></span></span><span><span></span><span>5%</span><span>⌋</span><span></span><span>+</span><span></span></span><span><span></span><span>1</span><span></span><span>=</span><span></span></span><span><span></span><span>433</span><span> </span><span><span>个点</span></span></span></span></span></span><p>5% 的点相当于一个月里约 <strong>36 个小时</strong>可以「超标」不算钱。偶发的突发流量，比如一场活动、一次发版，基本都会落在这 5% 里被去掉。模拟数据里，那场直播完全没有影响 95 峰值。</p><p>95 峰值对客户和厂商都比较公平：客户的偶发尖峰被容忍了，厂商又能按接近真实容量需求的水平收费。这也是为什么运营商和 CDN 厂商之间、CDN 厂商和大客户之间，绝大多数都用 95 结算。</p><p>也正因为 95 这么普遍，才有了「按 95 结算的总成本，怎么分摊给共享带宽的多个客户」这个问题，这就是我那项专利要解决的，见<a href="/posts/cdn-bandwidth-cost-allocation-patent/">《我的第一项专利：用「95 区间」把 CDN 带宽成本分得更准》</a>。</p></section><section><h3>3. 日峰值月平均：每天的高峰都要付钱<a href="#3-日峰值月平均每天的高峰都要付钱"><span>#</span></a></h3><p>每天取一个峰值，再对一个月的日峰值求平均：</p><span><span><span>日峰月均=1N∑d=1Nmax⁡(第 d 天的 288 个点)\text{日峰月均} = \frac{1}{N}\sum_{d=1}^{N} \max(\text{第 } d \text{ 天的 288 个点})</span><span><span><span></span><span><span>日峰月均</span></span><span></span><span>=</span><span></span></span><span><span></span><span><span></span><span><span><span><span><span><span></span><span><span>N</span></span></span><span><span></span><span></span></span><span><span></span><span><span>1</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span><span></span></span><span></span><span><span><span><span><span><span></span><span><span><span>d</span><span>=</span><span>1</span></span></span></span><span><span></span><span><span>∑</span></span></span><span><span></span><span><span><span>N</span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span><span></span><span>max</span><span>(</span><span><span>第</span><span> </span></span><span>d</span><span><span> </span><span>天的</span><span> 288 </span><span>个点</span></span><span>)</span></span></span></span></span><p>它对单日尖峰比月峰值宽容：那场直播只抬高了一天的峰值，被 30 天一平均就摊薄了。但它对「每天都有高峰」的业务不友好，每天晚高峰的那个最高点都被计入。模拟数据里，日峰月均（756）比 95 峰值（678）高出约 11%。</p><p>日峰月均更适合<strong>带宽曲线每天都很平稳</strong>的客户，比如下载、点播类业务；对于有明显晚高峰、偶尔还有活动的业务，95 通常更划算。</p></section><section><h3>4. 月第四峰值<a href="#4-月第四峰值"><span>#</span></a></h3><p>每天取峰值，然后取一个月里第四大的那个。意思是一个月里可以有 3 天「超标」不算。它介于月峰值和日峰月均之间，一些云厂商把它作为可选的计费方式。</p></section><section><h3>5. 夜间带宽减半<a href="#5-夜间带宽减半"><span>#</span></a></h3><p>在合同约定的夜间时段（比如凌晨），带宽按一半计入，再参与 95 或其他方式的计算。这是一种鼓励客户把预热、批量下发这类可以错峰的流量挪到夜间的手段，对厂商来说，夜间本来就是带宽的低谷，闲着也是闲着。</p><p>要注意的是，它只对<strong>高峰落在夜间</strong>的客户有意义。模拟客户的高峰在晚上 21 点，夜间本来就不在前 5% 里，所以开不开夜间减半，95 峰值都是 678，没有变化。</p></section><section><h3>6. 闲忙时计费<a href="#6-闲忙时计费"><span>#</span></a></h3><p>把一天分成忙时和闲时两段，分别计算计费带宽，或者用不同的单价。思路和夜间减半一样：用价格引导客户错峰，让厂商的带宽资源在一天里用得更均匀。</p></section></section>
<section><h2>五、其他计费方式<a href="#五其他计费方式"><span>#</span></a></h2><ul>
<li><strong>请求数计费</strong>：按请求次数收费，常见于 HTTPS 请求、动态加速、边缘计算等场景，因为这些场景的成本更多花在 CPU（TLS 握手、回源、计算）上，而不是带宽上。在火山 CDN 计费推量里，QPS 计费的客户就是按小时推送请求数据。</li>
<li><strong>增值服务单独计费</strong>：比如 HTTPS、WAF、实时日志、图片处理等，一般单独计价，不并入带宽或流量。</li>
</ul></section>
<section><h2>六、容易忽略的细节<a href="#六容易忽略的细节"><span>#</span></a></h2><p>账单对不上，很多时候不是计费方式本身，而是这些细节：</p><p><strong>1. 计费区域。</strong> 不同区域的带宽成本差别很大，海外普遍比国内贵，各区域通常<strong>分别计费</strong>：国内的 95 和海外的 95 各算各的，单价也不同。一个客户的流量往海外迁移，即使总量不变，账单也会上涨。</p><p><strong>2. 包头系数。</strong> CDN 计费的流量来自节点日志，记录的是应用层（HTTP）的数据量；而实际在网络上传输时，还有 TCP/IP 包头、重传、TLS 握手等开销。所以很多厂商会在日志流量上乘一个大于 1 的<strong>包头系数</strong>，来近似网络层的真实流量。我在字节做火山 CDN 的计费核对时，包头系数就是独立核算计费值时必须考虑的因素之一。</p><p><strong>3. 统计口径和时间。</strong> 5 分钟的点是按什么时区切分的？月份按自然月还是账期？有的厂商对 95 的取点规则有细微差别（比如 8640 个点还是按实际天数）。这些在厂商和客户之间对账时，都要事先对齐，否则差几个点就会对不上。</p><p><strong>4. 多个域名、多个项目合并计算。</strong> 一个客户有多个域名，是先把所有域名的带宽在每个 5 分钟点上加起来，再算 95；还是每个域名各算 95 再相加？两者结果不同：前者因为各域名的高峰可能错开，通常更低。合同里一般约定按客户（或按项目）合并计算。</p></section>
<section><h2>七、站在两边看：该选哪种计费方式<a href="#七站在两边看该选哪种计费方式"><span>#</span></a></h2><p><strong>站在客户这边</strong>，选择的关键是<strong>带宽利用率</strong>，也就是平均带宽和计费带宽的比值：</p><span><span><span>利用率=月平均带宽计费带宽\text{利用率} = \frac{\text{月平均带宽}}{\text{计费带宽}}</span><span><span><span></span><span><span>利用率</span></span><span></span><span>=</span><span></span></span><span><span></span><span><span></span><span><span><span><span><span><span></span><span><span><span>计费带宽</span></span></span></span><span><span></span><span></span></span><span><span></span><span><span><span>月平均带宽</span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span><span></span></span></span></span></span></span><p>利用率高，说明带宽曲线平，按带宽买更划算；利用率低，说明大部分时间带宽是闲着的，按流量买更划算。具体的平衡点，可以用前面那个换算来算：</p><span><span><span>按带宽计费折合的每 GB 单价=带宽单价（元/Mbps/月）324 GB×利用率\text{按带宽计费折合的每 GB 单价} = \frac{\text{带宽单价（元/Mbps/月）}}{324\ \text{GB} \times \text{利用率}}</span><span><span><span></span><span><span>按带宽计费折合的每</span><span> GB </span><span>单价</span></span><span></span><span>=</span><span></span></span><span><span></span><span><span></span><span><span><span><span><span><span></span><span><span>324</span><span> </span><span><span>GB</span></span><span></span><span>×</span><span></span><span><span>利用率</span></span></span></span><span><span></span><span></span></span><span><span></span><span><span><span>带宽单价（元</span><span>/Mbps/</span><span>月）</span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span><span></span></span></span></span></span></span><p>把它和流量单价比一比，就知道哪种更便宜。模拟客户按 95 算的利用率约为 472 ÷ 678 ≈ 70%，曲线很平，按带宽计费大概率更划算。</p><p><strong>站在厂商这边</strong>，我做账单时最深的体会是：计费方式本质上是<strong>把自己的成本结构翻译给客户</strong>。厂商向运营商按 95 付费，就倾向于向客户按 95 收费；夜间带宽闲置，就推出夜间减半；HTTPS 消耗 CPU，就按请求数收费。理解了厂商的成本在哪里，就能理解每一种计费方式为什么存在，作为客户也就知道该怎么谈价格了。</p><p>95 计费还带来了一个有意思的现象：高峰和大盘错开的客户，接入后几乎不增加厂商的成本，厂商因此可以把同一份带宽卖给多个客户。这就是错峰和超卖，详见<a href="/posts/cdn-off-peak-and-overselling/">《CDN 的错峰与超卖》</a>；接入新客户前怎么据此测算最低价，见<a href="/posts/cdn-new-customer-pricing/">《CDN 新客户接入前的价格测算》</a>。</p><p><strong>站在采购方内部</strong>，比如字节融合 CDN 这样同时向多家厂商采购、再分给内部业务的平台，还要多解决一个问题：厂商按整体 95 收的钱，怎么公平地分给每个业务、每个域名。单纯取 95 时间点那一刻的占比会失真，这也是我后来设计「95 区间」分摊算法的起点。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://tracerzzz.cn/posts/multi-cdn-content-management/</id>
      <title type="text">一个接口管多家 CDN：多厂商刷新预热的适配层设计</title>
      <published>2026-10-25T10:00:00.000Z</published>
      <updated>2026-10-25T10:00:00.000Z</updated>
      <author><name>tracerZzz</name></author>
      <link rel="alternate" href="https://tracerzzz.cn/posts/multi-cdn-content-management/"/>
      <summary type="text">我在字节负责火山多云 CDN 和融合 CDN 的多厂商内容管理（刷新、预热）。这篇讲怎么用适配器模式屏蔽厂商差异，怎么在多云层做配额和限频，怎么按每家厂商的限制拆分任务，怎么用 RocketMQ、Redis、MongoDB 实现分布式下发、共享配额、重试和进度汇总，以及后来如何借助 AI 把新厂商接入时间缩短到一两天。</summary>
      <content type="html"><![CDATA[<p>很多公司会同时用好几家 CDN：容灾、调度、成本都需要。但每家厂商的控制台和 API 都不一样，刷新一批 URL，要分别登录几个控制台、调几套接口。</p>
<p>我在字节参与过火山引擎多云 CDN 的商业化，负责其中的<strong>内容管理</strong>模块：客户只调用多云的一套接口，多云平台把刷新、预热任务分发到客户接入的各家厂商，汇总结果后统一返回。同一套能力，对内也服务字节的融合 CDN 平台，两条线并行维护。</p>
<p>这篇整理这个模块的设计思路。</p>
<blockquote><p><strong>说明</strong>：文中的架构图和代码都是为了说明思路而简化的示例，不是原始实现。实际系统还要处理很多这里没有展开的细节，比如各厂商千差万别的边界情况、任务去重、异常告警、灰度发布和监控等。</p></blockquote>
<section><h2>一、要解决的问题<a href="#一要解决的问题"><span>#</span></a></h2><p>内容管理看起来简单，就是把一批 URL 发给厂商。难点在于<strong>各家厂商的差异和限制</strong>：</p><ul>
<li><strong>接口不同</strong>：认证方式（AK/SK 签名、Token、各种自定义签名算法）、入参出参格式、任务状态的定义都不一样；</li>
<li><strong>单次提交上限不同</strong>：有的厂商一次最多提交几十个 URL，有的上百个；</li>
<li><strong>频率限制不同</strong>：每秒能调用几次、超了是直接报错还是排队，各不相同；</li>
<li><strong>总量限制不同</strong>：很多厂商对每个账号每天能刷新、预热的 URL 总数有配额；</li>
<li><strong>都是异步的</strong>：提交成功只代表厂商收到了任务，真正执行完要另外查询。</li>
</ul><p>多云平台要做的，就是把这些差异全部吸收掉，让客户感觉只在用「一家」CDN。</p></section>
<section><h2>二、整体架构<a href="#二整体架构"><span>#</span></a></h2><div><div><div><div><span><p>统一接口<br /><br />单次 ≤ 1000 URL，50 QPS</p></span></div><div><span><p>主任务</p></span></div><div><span><p>按厂商拆分的子任务</p></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>共享各厂商配额</p></span></div><div><span><p>共享各厂商配额</p></span></div><div><span><p>共享各厂商配额</p></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>查询执行状态</p></span></div><div><span><p>更新子任务、主任务进度</p></span></div><div><span><p>客户 / 融合 CDN</p></span></div><div><span><p>多云内容管理 API</p></span></div><div><span><p>MongoDB<br /><br />主任务 / 子任务</p></span></div><div><span><p>RocketMQ</p></span></div><div><span><p>下发服务 1</p></span></div><div><span><p>下发服务 2</p></span></div><div><span><p>下发服务 N</p></span></div><div><span><p>Redis</p></span></div><div><span><p>厂商适配层</p></span></div><div><span><p>厂商 A</p></span></div><div><span><p>厂商 B</p></span></div><div><span><p>厂商 C</p></span></div><div><span><p>定时轮询</p></span></div>
</div><div><div><span><p>统一接口<br /><br />单次 ≤ 1000 URL，50 QPS</p></span></div><div><span><p>主任务</p></span></div><div><span><p>按厂商拆分的子任务</p></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>共享各厂商配额</p></span></div><div><span><p>共享各厂商配额</p></span></div><div><span><p>共享各厂商配额</p></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>查询执行状态</p></span></div><div><span><p>更新子任务、主任务进度</p></span></div><div><span><p>客户 / 融合 CDN</p></span></div><div><span><p>多云内容管理 API</p></span></div><div><span><p>MongoDB<br /><br />主任务 / 子任务</p></span></div><div><span><p>RocketMQ</p></span></div><div><span><p>下发服务 1</p></span></div><div><span><p>下发服务 2</p></span></div><div><span><p>下发服务 N</p></span></div><div><span><p>Redis</p></span></div><div><span><p>厂商适配层</p></span></div><div><span><p>厂商 A</p></span></div><div><span><p>厂商 B</p></span></div><div><span><p>厂商 C</p></span></div><div><span><p>定时轮询</p></span></div>
</div></div></div><p>一次刷新请求的生命周期：</p><div><div><div>定时轮询厂商（经适配器）Redis下发服务RocketMQMongoDB多云 API客户定时轮询厂商（经适配器）Redis下发服务RocketMQMongoDB多云 API客户alt[配额充足][配额不足]loop[每个主任务定时执行，直到全部子任务结束]提交刷新（≤ 1000 URL）1多云层配额、限频校验2创建主任务3按厂商和单次上限拆出子任务4投递子任务5返回主任务 ID6消费子任务7检查并扣减该厂商配额8提交 URL9厂商任务 ID10子任务 → 已提交11延迟后重新投递12查询子任务执行状态13更新子任务，汇总主任务状态和完成率14查询主任务进度15
</div><div>定时轮询厂商（经适配器）Redis下发服务RocketMQMongoDB多云 API客户定时轮询厂商（经适配器）Redis下发服务RocketMQMongoDB多云 API客户alt[配额充足][配额不足]loop[每个主任务定时执行，直到全部子任务结束]提交刷新（≤ 1000 URL）1多云层配额、限频校验2创建主任务3按厂商和单次上限拆出子任务4投递子任务5返回主任务 ID6消费子任务7检查并扣减该厂商配额8提交 URL9厂商任务 ID10子任务 → 已提交11延迟后重新投递12查询子任务执行状态13更新子任务，汇总主任务状态和完成率14查询主任务进度15
</div></div></div><ol>
<li>客户调用多云接口提交 URL，多云层先做<strong>自己的配额和限频</strong>；</li>
<li>生成一个<strong>主任务</strong>，存进 MongoDB；</li>
<li>按客户接入的厂商、以及每家厂商的单次上限，把主任务拆成多个<strong>子任务</strong>，投递到 RocketMQ 排队；</li>
<li>多台下发服务消费子任务，通过 Redis 检查并扣减该厂商的<strong>共享配额</strong>，再经适配层调用厂商接口；</li>
<li>每个主任务开启<strong>定时轮询</strong>，查询各子任务在厂商侧的执行状态，汇总成主任务的状态和完成率。</li>
</ol><p>下面逐个展开。</p></section>
<section><h2>三、适配器模式：屏蔽厂商差异<a href="#三适配器模式屏蔽厂商差异"><span>#</span></a></h2><p>所有厂商实现同一个接口，多云平台的其他部分只和这个接口打交道，完全不知道背后是哪家厂商：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// 统一的任务类型和状态，由多云平台定义</span></div></div><div><div><div>2</div></div><div><span>type</span><span> </span><span>TaskType</span><span> </span><span>string</span><span> </span><span>// refresh_url / refresh_dir / preload</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span>type</span><span> </span><span>VendorTaskStatus</span><span> </span><span>string</span><span> </span><span>// pending / running / success / failed</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>// 每家厂商的能力和限制，作为适配器的一部分声明出来</span></div></div><div><div><div>7</div></div><div><span>type</span><span> </span><span>Limits</span><span> </span><span>struct</span><span> {</span></div></div><div><div><div>8</div></div><div><span>  </span><span>MaxURLsPerCall</span><span> </span><span>int</span><span>           </span><span>// 单次提交上限</span></div></div><div><div><div>9</div></div><div><span>  </span><span>CallsPerSecond</span><span> </span><span>int</span><span>           </span><span>// 接口频率限制</span></div></div><div><div><div>10</div></div><div><span>  </span><span>DailyQuota</span><span>     </span><span>map</span><span>[</span><span>TaskType</span><span>]</span><span>int</span><span> </span><span>// 每日总量配额，0 表示不限</span></div></div><div><div><div>11</div></div><div><span>}</span></div></div><div><div><div>12</div></div><div>
</div></div><div><div><div>13</div></div><div><span>type</span><span> </span><span>Adapter</span><span> </span><span>interface</span><span> {</span></div></div><div><div><div>14</div></div><div><span>  </span><span>Name</span><span>() </span><span>string</span></div></div><div><div><div>15</div></div><div><span>  </span><span>Limits</span><span>() </span><span>Limits</span></div></div><div><div><div>16</div></div><div><span>  </span><span>// 提交任务，返回厂商侧的任务 ID</span></div></div><div><div><div>17</div></div><div><span>  </span><span>Submit</span><span><span>(</span><span>ctx</span><span> </span></span><span>context</span><span>.</span><span>Context</span><span><span>, </span><span>cred</span><span> </span></span><span>Credential</span><span><span>, </span><span>typ</span><span> </span></span><span>TaskType</span><span><span>, </span><span>urls</span><span> []</span></span><span>string</span><span><span>) (</span><span>vendorTaskID</span><span> </span></span><span>string</span><span><span>, </span><span>err</span><span> </span></span><span>error</span><span>)</span></div></div><div><div><div>18</div></div><div><span>  </span><span>// 查询执行状态，把厂商自己的状态翻译成统一状态</span></div></div><div><div><div>19</div></div><div><span>  </span><span>Query</span><span><span>(</span><span>ctx</span><span> </span></span><span>context</span><span>.</span><span>Context</span><span><span>, </span><span>cred</span><span> </span></span><span>Credential</span><span><span>, </span><span>vendorTaskID</span><span> </span></span><span>string</span><span>) (</span><span>VendorTaskStatus</span><span>, </span><span>error</span><span>)</span></div></div><div><div><div>20</div></div><div><span>}</span></div></div><div><div><div>21</div></div><div>
</div></div><div><div><div>22</div></div><div><span>var</span><span> </span><span>registry</span><span><span> </span><span>=</span><span> </span></span><span>map</span><span>[</span><span>string</span><span>]</span><span>Adapter</span><span>{}</span></div></div><div><div><div>23</div></div><div>
</div></div><div><div><div>24</div></div><div><span>func</span><span> </span><span>Register</span><span><span>(</span><span>a</span><span> </span></span><span>Adapter</span><span>) { </span><span>registry</span><span>[</span><span>a</span><span>.</span><span>Name</span><span><span>()] </span><span>=</span><span> </span></span><span>a</span><span> }</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>每家厂商一个实现，认证签名、参数拼装、状态翻译都封装在里面。接入一家新厂商，就是新写一个适配器并注册进去，核心流程一行都不用改。</p><p><strong>限制也是适配器的一部分</strong>。单次上限、频率、配额这些信息跟着适配器走，拆分和限流的逻辑读取它们，而不是写死在流程里。这样不同厂商的差异就收敛在一个地方。</p></section>
<section><h2>四、多云层的配额与限频<a href="#四多云层的配额与限频"><span>#</span></a></h2><p>客户面对的是多云平台，所以多云层先要有一套<strong>自己的、统一的限制</strong>：</p><ul>
<li><strong>单次最多 1000 个 URL</strong>；</li>
<li><strong>接口频率 50 QPS</strong>，也就是每秒最多可以接收 5 万个 URL。</li>
</ul><p>这组数值是参考各家主流厂商的能力，取的一个中间值。它带来两种情况：</p><ul>
<li>如果多云的单次上限比某家厂商的大，提交给这家厂商时要<strong>拆分</strong>成多批；</li>
<li>如果比厂商的小，就直接提交。</li>
</ul><p>这样设计的好处是，客户不需要了解每家厂商的限制；代价是多云层要负责把一个「大请求」翻译成符合每家厂商规矩的若干「小请求」。</p><p>另外，如果厂商侧有<strong>每日总量限制</strong>，这是多云平台无法替厂商突破的。这类限制会同步给客户，让客户去厂商那里申请提高限额。</p></section>
<section><h2>五、任务拆分与排队：RocketMQ<a href="#五任务拆分与排队rocketmq"><span>#</span></a></h2><p>客户的请求是突发的，比如一次发版要刷新几万个 URL；而厂商接口的承受能力是固定的。中间必须有一层缓冲，所以子任务会进 <strong>RocketMQ</strong> 排队，由多台下发服务按各厂商的节奏消费。</p><p>拆分逻辑按每家厂商的单次上限切批：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>func</span><span> </span><span>split</span><span><span>(</span><span>urls</span><span> []</span></span><span>string</span><span><span>, </span><span>limit</span><span> </span></span><span>int</span><span>) [][]</span><span>string</span><span> {</span></div></div><div><div><div>2</div></div><div><span>  </span><span>var</span><span> </span><span>batches</span><span> [][]</span><span>string</span></div></div><div><div><div>3</div></div><div><span>  </span><span>for</span><span> </span><span>len</span><span>(</span><span>urls</span><span><span>) </span><span>&gt;</span><span> </span></span><span>limit</span><span> {</span></div></div><div><div><div>4</div></div><div><span>    </span><span>batches</span><span><span> </span><span>=</span><span> </span></span><span>append</span><span>(</span><span>batches</span><span>, </span><span>urls</span><span>[:</span><span>limit</span><span>])</span></div></div><div><div><div>5</div></div><div><span>    </span><span>urls</span><span><span> </span><span>=</span><span> </span></span><span>urls</span><span>[</span><span>limit</span><span>:]</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>7</div></div><div><span>  </span><span>if</span><span> </span><span>len</span><span>(</span><span>urls</span><span><span>) </span><span>&gt;</span><span> </span></span><span>0</span><span> {</span></div></div><div><div><div>8</div></div><div><span>    </span><span>batches</span><span><span> </span><span>=</span><span> </span></span><span>append</span><span>(</span><span>batches</span><span>, </span><span>urls</span><span>)</span></div></div><div><div><div>9</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>10</div></div><div><span>  </span><span>return</span><span> </span><span>batches</span></div></div><div><div><div>11</div></div><div><span>}</span></div></div><div><div><div>12</div></div><div>
</div></div><div><div><div>13</div></div><div><span>// 一个主任务，按客户接入的每家厂商分别拆成子任务</span></div></div><div><div><div>14</div></div><div><span>func</span><span> </span><span>dispatch</span><span><span>(</span><span>task</span><span> </span><span>*</span></span><span>MainTask</span><span>) </span><span>error</span><span> {</span></div></div><div><div><div>15</div></div><div><span>  </span><span>for</span><span> </span><span>_</span><span>, </span><span>vendor</span><span><span> </span><span>:=</span><span> </span></span><span>range</span><span> </span><span>task</span><span>.</span><span>Vendors</span><span> {</span></div></div><div><div><div>16</div></div><div><span>    </span><span>a</span><span><span> </span><span>:=</span><span> </span></span><span>registry</span><span>[</span><span>vendor</span><span>]</span></div></div><div><div><div>17</div></div><div><span>    </span><span>for</span><span> </span><span>_</span><span>, </span><span>batch</span><span><span> </span><span>:=</span><span> </span></span><span>range</span><span> </span><span>split</span><span>(</span><span>task</span><span>.</span><span>URLs</span><span>, </span><span>a</span><span>.</span><span>Limits</span><span>().</span><span>MaxURLsPerCall</span><span>) {</span></div></div><div><div><div>18</div></div><div><span>      </span><span>sub</span><span><span> </span><span>:=</span><span> </span></span><span>NewSubTask</span><span>(</span><span>task</span><span>.</span><span>ID</span><span>, </span><span>vendor</span><span>, </span><span>batch</span><span>)</span></div></div><div><div><div>19</div></div><div><span>      </span><span>if</span><span> </span><span>err</span><span><span> </span><span>:=</span><span> </span></span><span>saveSubTask</span><span>(</span><span>sub</span><span>); </span><span>err</span><span><span> </span><span>!=</span><span> </span></span><span>nil</span><span> { </span><span>// 子任务先落库，再投递</span></div></div><div><div><div>20</div></div><div><span>        </span><span>return</span><span> </span><span>err</span></div></div><div><div><div>21</div></div><div><span><span>      </span></span><span>}</span></div></div><div><div><div>22</div></div><div><span>      </span><span>if</span><span> </span><span>err</span><span><span> </span><span>:=</span><span> </span></span><span>mq</span><span>.</span><span>Send</span><span>(</span><span>"content-task"</span><span>, </span><span>sub</span><span>.</span><span>ID</span><span>); </span><span>err</span><span><span> </span><span>!=</span><span> </span></span><span>nil</span><span> {</span></div></div><div><div><div>23</div></div><div><span>        </span><span>return</span><span> </span><span>err</span></div></div><div><div><div>24</div></div><div><span><span>      </span></span><span>}</span></div></div><div><div><div>25</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>26</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>27</div></div><div><span>  </span><span>return</span><span> </span><span>nil</span></div></div><div><div><div>28</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>实际的拆分比这复杂一些：有的厂商对目录刷新、URL 刷新、预热分别有不同上限，有的对单个任务里的域名数量也有要求，所以每家厂商有自己单独的拆分逻辑。这部分只能靠深入了解每家厂商的机制，再一点点适配。</p><p>主任务和子任务存在 <strong>MongoDB</strong> 里：一个主任务下挂着数量不定的子任务，每家厂商返回的任务信息格式也不一样，文档型数据库存这种结构很自然。</p></section>
<section><h2>六、分布式共享配额：Redis<a href="#六分布式共享配额redis"><span>#</span></a></h2><p>下发服务有多台，它们同时在给同一家厂商提交任务。厂商的频率和总量限制是针对<strong>整个账号</strong>的，不是针对某一台机器的。如果每台服务器各自计数，加起来就会超限。</p><p>所以配额计数放在 <strong>Redis</strong> 里，所有下发服务共享。Redis 的键可以设置过期时间，<strong>正好对应厂商限额的时间窗口</strong>：限每秒就让键 1 秒过期，限每天就让键在当天结束时过期，窗口一过，计数自动清零。</p><div><div><div><div><span><p>Redis：厂商 A 的配额计数</p></span></div><div><span><p>多台下发服务（同时给厂商 A 提交）</p></span></div><div><span><p>INCRBY 原子计数</p></span></div><div><span><p>INCRBY 原子计数</p></span></div><div><span><p>INCRBY 原子计数</p></span></div><div><span><p>未超限：放行</p></span></div><div><span><p>超限：DECRBY 回退</p></span></div><div><span><p>窗口结束，键过期</p></span></div><div><span><p>下发服务 1<br /><br />本批 100 个</p></span></div><div><span><p>下发服务 2<br /><br />本批 100 个</p></span></div><div><span><p>下发服务 3<br /><br />本批 100 个</p></span></div><div><span><p>quota:A:账号:当前窗口<br /><br />已用 / 上限<br /><br />TTL = 窗口剩余时间</p></span></div><div><span><p>调用厂商 A 接口</p></span></div><div><span><p>子任务延迟重投</p></span></div><div><span><p>计数自动清零</p></span></div>
</div><div><div><span><p>Redis：厂商 A 的配额计数</p></span></div><div><span><p>多台下发服务（同时给厂商 A 提交）</p></span></div><div><span><p>INCRBY 原子计数</p></span></div><div><span><p>INCRBY 原子计数</p></span></div><div><span><p>INCRBY 原子计数</p></span></div><div><span><p>未超限：放行</p></span></div><div><span><p>超限：DECRBY 回退</p></span></div><div><span><p>窗口结束，键过期</p></span></div><div><span><p>下发服务 1<br /><br />本批 100 个</p></span></div><div><span><p>下发服务 2<br /><br />本批 100 个</p></span></div><div><span><p>下发服务 3<br /><br />本批 100 个</p></span></div><div><span><p>quota:A:账号:当前窗口<br /><br />已用 / 上限<br /><br />TTL = 窗口剩余时间</p></span></div><div><span><p>调用厂商 A 接口</p></span></div><div><span><p>子任务延迟重投</p></span></div><div><span><p>计数自动清零</p></span></div>
</div></div></div><p>检查和扣减必须避免竞争：如果先 <code>GET</code> 读出已用量、判断够不够、再写回去，两台服务器可能同时读到「还剩 100」，都去提交 100 个，就超了。</p><p>解决办法是利用 <strong><code>INCRBY</code> 本身是原子的</strong>：不先读再写，而是<strong>先加、再判断</strong>。Redis 单线程执行命令，多台服务并发 <code>INCRBY</code> 时，每台拿到的都是自己加完之后的结果，不会读到同一个值。加完发现超过上限，就 <code>DECRBY</code> 把这次的数量退回去，本次不放行。</p><p>下发服务用 Go（go-redis）直接操作 Redis，大致是这样（示例）：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>// 申请本次要提交的 n 个 URL 的配额；limit 为该厂商在窗口内的上限</span></div></div><div><div><div>2</div></div><div><span>func</span><span> </span><span>acquire</span><span><span>(</span><span>ctx</span><span> </span></span><span>context</span><span>.</span><span>Context</span><span><span>, </span><span>rdb</span><span> </span><span>*</span></span><span>redis</span><span>.</span><span>Client</span><span><span>, </span><span>vendor</span><span>, </span><span>account</span><span> </span></span><span>string</span><span>,</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>n</span><span>, </span><span>limit</span><span> </span><span>int64</span><span><span>, </span><span>window</span><span> </span></span><span>time</span><span>.</span><span>Duration</span><span>) (</span><span>bool</span><span>, </span><span>error</span><span>) {</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>  </span><span>// 键名带上窗口编号，窗口切换时自然换成新键</span></div></div><div><div><div>6</div></div><div><span>  </span><span>key</span><span><span> </span><span>:=</span><span> </span></span><span>fmt</span><span>.</span><span>Sprintf</span><span>(</span><span>"quota:</span><span>%s</span><span>:</span><span>%s</span><span>:</span><span>%d</span><span>"</span><span>, </span><span>vendor</span><span>, </span><span>account</span><span>,</span></div></div><div><div><div>7</div></div><div><span>    </span><span>time</span><span>.</span><span>Now</span><span>().</span><span>Unix</span><span><span>()</span><span>/</span></span><span>int64</span><span>(</span><span>window</span><span>.</span><span>Seconds</span><span>()))</span></div></div><div><div><div>8</div></div><div>
</div></div><div><div><div>9</div></div><div><span>  </span><span>// 窗口内第一次使用时创建键并设置过期时间；键已存在则什么都不做</span></div></div><div><div><div>10</div></div><div><span>  </span><span>if</span><span> </span><span>err</span><span><span> </span><span>:=</span><span> </span></span><span>rdb</span><span>.</span><span>SetNX</span><span>(</span><span>ctx</span><span>, </span><span>key</span><span>, </span><span>0</span><span>, </span><span>window</span><span>).</span><span>Err</span><span>(); </span><span>err</span><span><span> </span><span>!=</span><span> </span></span><span>nil</span><span> {</span></div></div><div><div><div>11</div></div><div><span>    </span><span>return</span><span> </span><span>false</span><span>, </span><span>err</span></div></div><div><div><div>12</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>13</div></div><div>
</div></div><div><div><div>14</div></div><div><span>  </span><span>// 原子地加上本次数量，返回加完之后的总量</span></div></div><div><div><div>15</div></div><div><span>  </span><span>used</span><span>, </span><span>err</span><span><span> </span><span>:=</span><span> </span></span><span>rdb</span><span>.</span><span>IncrBy</span><span>(</span><span>ctx</span><span>, </span><span>key</span><span>, </span><span>n</span><span>).</span><span>Result</span><span>()</span></div></div><div><div><div>16</div></div><div><span>  </span><span>if</span><span> </span><span>err</span><span><span> </span><span>!=</span><span> </span></span><span>nil</span><span> {</span></div></div><div><div><div>17</div></div><div><span>    </span><span>return</span><span> </span><span>false</span><span>, </span><span>err</span></div></div><div><div><div>18</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>19</div></div><div><span>  </span><span>if</span><span> </span><span>used</span><span><span> </span><span>&gt;</span><span> </span></span><span>limit</span><span> {</span></div></div><div><div><div>20</div></div><div><span>    </span><span>// 超限：把本次数量退回去，不放行</span></div></div><div><div><div>21</div></div><div><span>    </span><span>rdb</span><span>.</span><span>DecrBy</span><span>(</span><span>ctx</span><span>, </span><span>key</span><span>, </span><span>n</span><span>)</span></div></div><div><div><div>22</div></div><div><span>    </span><span>return</span><span> </span><span>false</span><span>, </span><span>nil</span></div></div><div><div><div>23</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>24</div></div><div><span>  </span><span>return</span><span> </span><span>true</span><span>, </span><span>nil</span></div></div><div><div><div>25</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>这里有两个细节：</p><ul>
<li><strong>先 <code>SET NX</code> 带过期时间，再 <code>INCRBY</code></strong>。如果反过来先 <code>INCRBY</code> 再单独 <code>EXPIRE</code>，进程恰好在两步之间挂掉，这个键就永远不过期，配额会一直被占着。<code>SET NX</code> 一条命令同时完成「创建」和「设置过期」，就没有这个问题。</li>
<li><strong>超限时的回退是安全的</strong>。在 <code>INCRBY</code> 和 <code>DECRBY</code> 之间，其他服务可能短暂看到一个偏高的已用量，结果只是它们这一次也被拒绝、稍后重试，不会导致超发。宁可偶尔少放行，也不能多放行。</li>
</ul><p>如果逻辑更复杂，比如要同时扣减「每秒频率」和「每日总量」两个计数器，并且要求两者同时成功或同时失败，就可以把这些步骤写成一段 Lua 脚本，在 Go 里用 <code>EVAL</code> 调用，由 Redis 一次性执行完。单个计数器的场景，直接用命令就足够了。</p><p>拿不到配额的子任务不会被丢弃，而是重新投回队列，稍后再提交，和调用失败一样走 3 次重试的逻辑。</p><p><strong>Redis 出问题怎么办？</strong> 这里的选择是<strong>宁可停，也不超发</strong>：申请配额时 Redis 报错，和配额不足一样处理，子任务重新入队重试，重试用完仍失败就标记为失败，绝不会在没有配额的情况下调用厂商接口。这是因为刷新、预热本身是异步任务，晚几分钟执行可以接受；而超出厂商限额，可能导致厂商侧报错甚至限制账号。另外，Redis 使用的是公司的高可用部署，实际很少出现不可用；每个主任务也有定时检查兜底，不会有任务卡住无人处理。</p><p>这里只是配额控制的基本思路，实际实现中还有不少细节没有展开，比如窗口边界上的计数、各厂商限额口径的差异（按 URL 数、按请求次数还是按目录）。</p></section>
<section><h2>七、失败重试与进度汇总<a href="#七失败重试与进度汇总"><span>#</span></a></h2><p><strong>重试</strong>：调用厂商接口失败（超时、限流、服务端错误）时，最多重试 3 次。仍然失败就不再重试，这个子任务标记为失败，但<strong>主任务照常完成</strong>，只是完成结果里会体现哪些子任务、哪家厂商失败了。</p><p>这样做的考虑是：一家厂商出问题，不应该拖住其他厂商的结果。客户能清楚地看到「厂商 A、B 已刷新成功，厂商 C 失败」，再决定要不要单独重试。</p><p><strong>重试策略跟着时效性走。</strong> 不同任务类型对时效的要求不一样，重试次数和主任务的检查周期可以按任务类型配置：</p>

<table><thead><tr><th>任务类型</th><th>时效要求</th><th>重试与检查策略</th></tr></thead><tbody><tr><td>刷新（URL、目录）</td><td>高：源站内容已更新，用户可能正在访问旧内容</td><td>重试次数少、检查周期短，失败尽快反馈给客户</td></tr><tr><td>预热</td><td>低：通常在活动或发版前提前进行</td><td>可以配置更多重试次数、更长的检查周期，耐心等配额和厂商恢复</td></tr></tbody></table><p>比如某家厂商当天的刷新配额用完了，刷新任务会在几次重试后直接失败，客户能立刻看到，去厂商那里申请提额，而不是让任务悬而未决；预热任务则可以多等一会儿再试。</p><p><strong>进度汇总</strong>：厂商的刷新、预热都是异步的，提交成功之后还要等它真正执行完。所以每个主任务都会开启一个<strong>定时轮询</strong>：</p><div><div><div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>主任务：刷新 3000 个 URL<br /><br />完成率 = 已结束子任务 / 子任务总数</p></span></div><div><span><p>厂商 A（单次上限 1000）</p></span></div><div><span><p>厂商 B（单次上限 500）</p></span></div><div><span><p>子任务 A-1 ✅ 成功</p></span></div><div><span><p>子任务 A-2 ✅ 成功</p></span></div><div><span><p>子任务 A-3 ⏳ 执行中</p></span></div><div><span><p>子任务 B-1 ✅ 成功</p></span></div><div><span><p>子任务 B-2 ❌ 重试 3 次后失败</p></span></div><div><span><p>……共 6 个</p></span></div>
</div><div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>主任务：刷新 3000 个 URL<br /><br />完成率 = 已结束子任务 / 子任务总数</p></span></div><div><span><p>厂商 A（单次上限 1000）</p></span></div><div><span><p>厂商 B（单次上限 500）</p></span></div><div><span><p>子任务 A-1 ✅ 成功</p></span></div><div><span><p>子任务 A-2 ✅ 成功</p></span></div><div><span><p>子任务 A-3 ⏳ 执行中</p></span></div><div><span><p>子任务 B-1 ✅ 成功</p></span></div><div><span><p>子任务 B-2 ❌ 重试 3 次后失败</p></span></div><div><span><p>……共 6 个</p></span></div>
</div></div></div><p>图中的厂商上限只是示意。同一批 URL 要分别提交给客户接入的每一家厂商，所以每家厂商各有一组子任务。</p><ul>
<li>查询所有未结束子任务在厂商侧的状态，经适配器翻译成统一状态后更新；</li>
<li>根据子任务的状态，汇总出主任务的状态和<strong>完成率</strong>；</li>
<li>所有子任务都结束（成功或失败）后，主任务结束，停止轮询。</li>
</ul><p>客户在多云的任务列表里看到的是一个主任务，点开可以看到它被拆成了哪些厂商的哪些子任务，以及每个子任务的执行情况。</p></section>
<section><h2>八、接入新厂商：从手工适配到 AI 辅助<a href="#八接入新厂商从手工适配到-ai-辅助"><span>#</span></a></h2><p>最初接入一家新厂商很慢。要先读完厂商的文档，弄清楚它的认证鉴权方式、内容管理的接口和限制，再手动写签名算法、请求和响应的结构体、状态映射，最后按适配器模式嵌入平台、联调测试。</p><p>后来我们开始借助 AI 来写适配器里最繁琐的部分：<strong>各厂商的认证鉴权实现，以及接口入参、出参的代码</strong>。这部分本质上是把厂商文档翻译成代码，规则明确、重复性高，正好是 AI 擅长的。人主要负责的，变成了确认厂商的限制和边界情况、设计拆分策略、联调验证。</p><p>结果是，接入一家新厂商从原来的较长周期，缩短到<strong>一两天就能完成开发并发布上线</strong>。</p><p>之所以能做到这一点，前提是前面的适配器设计：接口和限制的抽象已经稳定，新厂商只需要填空，AI 生成的代码有明确的边界和约束，验证起来也简单。</p></section>
<section><h2>九、总结<a href="#九总结"><span>#</span></a></h2><p>回头看，这个模块的几个关键决策：</p><ol>
<li><strong>适配器模式</strong>把厂商差异收敛到一处，核心流程和厂商无关；</li>
<li><strong>多云层先定义自己的统一限制</strong>，再负责翻译成每家厂商的规矩，客户不需要了解厂商细节；</li>
<li><strong>消息队列削峰</strong>，把突发请求转成平稳的下发节奏；</li>
<li><strong>Redis 共享配额</strong>，用过期时间对齐厂商的限额窗口，用 INCRBY 的原子性保证多台服务不超发；</li>
<li><strong>失败隔离</strong>，一家厂商失败不影响其他厂商，结果透明地展示给客户；</li>
<li><strong>稳定的抽象让 AI 辅助真正落地</strong>，接入新厂商从「写一个系统」变成「填一个模板」。</li>
</ol></section>]]></content>
    </entry>
    <entry>
      <id>https://tracerzzz.cn/posts/workflow-state-machine-design/</id>
      <title type="text">从业务里长出来的状态机：一个资源建设审批流平台的设计与反思</title>
      <published>2026-10-25T09:00:00.000Z</published>
      <updated>2026-10-25T09:00:00.000Z</updated>
      <author><name>tracerZzz</name></author>
      <link rel="alternate" href="https://tracerzzz.cn/posts/workflow-state-machine-design/"/>
      <summary type="text">我在字节从零设计开发过一个 CDN 资源建设流程平台，管理物理机、带宽、IP 从需求发起到交付的全过程，状态机和审批流都是自己实现的。这篇记录当时的设计：多级评审、乐观锁、基于 MongoDB 的变更 diff 审计、父子任务自动结单、事务化回滚、按平台协商的轮询；以及现在回头看，状态机和业务规则应该怎么解耦。</summary>
      <content type="html"><![CDATA[<p>在字节做 CDN 的时候，我从零设计开发过一个资源建设流程平台（内部叫 CRM）。它管的是 CDN 最主要的几项成本：物理机、带宽、IP。一个资源需求从发起、评审、资源团队评估，到派给各个建设平台、最终交付，整个过程都在这个平台上流转。后端用 Go 实现，由我一个人负责，状态机和审批流都是自己写的。</p>
<p>这篇前半部分记录当时的设计，后半部分是回头看的反思：哪些地方做对了，哪些地方如果重来会换一种做法。文中的代码是按场景整理的示例，不是当时的原始实现。</p>
<section><h2>一、业务背景<a href="#一业务背景"><span>#</span></a></h2><p>资源建设原来主要靠线下沟通：需求方在群里提，评审靠拉人对齐，建设团队各自排期，进度靠人去问。问题很明显：</p><ul>
<li><strong>需求不规范</strong>：同样是要带宽，有人写清了区域和运营商，有人只写了一个总量；</li>
<li><strong>进度不透明</strong>：一个需求卡在哪个环节、谁在处理，只能挨个问；</li>
<li><strong>无法追溯</strong>：事后想查某批机器是谁批的、数量被谁改过、什么时候交付的，找不到记录。</li>
</ul><p>平台要做的事很明确：<strong>把流程线上化，每个节点的进度可视化、每一次变更都可追溯</strong>。</p></section>
<section><h2>二、流程与状态<a href="#二流程与状态"><span>#</span></a></h2><div><div><div><div><span></span></div><div><span><p>提交</p></span></div><div><span><p>驳回（记录原因）</p></span></div><div><span><p>评审通过</p></span></div><div><span><p>驳回（记录原因）</p></span></div><div><span><p>评估通过，下发到各资源建设平台</p></span></div><div><span><p>修改后重新提交</p></span></div><div><span><p>所有子任务完成，自动结单</p></span></div><div><span></span></div><div><span><p>草稿</p></span></div><div><span><p>待评审</p></span></div><div><span><p>已驳回</p></span></div><div><span><p>资源评估</p></span></div><div><span><p>建设中</p></span></div><div><span><p>已交付</p></span></div>
</div><div><div><span></span></div><div><span><p>提交</p></span></div><div><span><p>驳回（记录原因）</p></span></div><div><span><p>评审通过</p></span></div><div><span><p>驳回（记录原因）</p></span></div><div><span><p>评估通过，下发到各资源建设平台</p></span></div><div><span><p>修改后重新提交</p></span></div><div><span><p>所有子任务完成，自动结单</p></span></div><div><span></span></div><div><span><p>草稿</p></span></div><div><span><p>待评审</p></span></div><div><span><p>已驳回</p></span></div><div><span><p>资源评估</p></span></div><div><span><p>建设中</p></span></div><div><span><p>已交付</p></span></div>
</div></div></div><p>整个流程有两级把关：</p><ol>
<li><strong>需求评审</strong>：确认需求本身是否合理；</li>
<li><strong>资源团队评估</strong>：评审通过后，根据资源类型和模板类型，流转到对应的资源团队，评估资源是否可以满足、怎么建设。</li>
</ol><p>两级都可以通过或驳回，<strong>每一次驳回都必须填写原因</strong>并记录下来。被驳回的需求可以修改后重新提交，重新走一遍评审。</p><p>评估通过后，需求按资源类型拆成多个建设任务，下发到各类资源建设平台。每次状态变更，都会给相关的人发飞书消息：发起人、审批人、跟进人收到的链接不同，审批人点进去直接就是审批页面。</p><p>需求的入口是<strong>模板</strong>：不同资源类型对应不同的模板，需求方必须按模板填写，精确到机器型号、带宽的服务区域和运营商等。需求在入口就是规范的，后面的评审和建设才有依据。</p></section>
<section><h2>三、几个关键设计<a href="#三几个关键设计"><span>#</span></a></h2><section><h3>1. 并发控制：乐观锁<a href="#1-并发控制乐观锁"><span>#</span></a></h3><p>两个审批人同时操作同一个单子，比如一个点通过、一个点驳回，状态不能被改两次。用的是乐观锁：更新时把旧状态和版本号作为条件。</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>UPDATE</span><span> requests</span></div></div><div><div><div>2</div></div><div><span>SET</span><span> </span><span>state</span><span><span> </span><span>=</span><span> </span></span><span>'evaluating'</span><span>, </span><span>version</span><span><span> </span><span>=</span><span> </span></span><span>version</span><span> + </span><span>1</span></div></div><div><div><div>3</div></div><div><span>WHERE</span><span><span> id </span><span>=</span><span> ? </span></span><span>AND</span><span> </span><span>state</span><span><span> </span><span>=</span><span> </span></span><span>'reviewing'</span><span> </span><span>AND</span><span> </span><span>version</span><span><span> </span><span>=</span><span> ?;</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>影响行数为 0，说明单子已经被别人处理过了，这次操作直接失败，提示刷新。审批场景冲突很少，乐观锁比加锁更轻，也不会出现锁等待。</p></section><section><h3>2. 变更审计：通用 diff，而不只是流转日志<a href="#2-变更审计通用-diff而不只是流转日志"><span>#</span></a></h3><p>「可追溯」是这个平台的核心诉求之一，我的做法比单纯记状态流转更进一步：</p><ul>
<li>单子本身落在业务库里；</li>
<li>另有一个独立的日志库，用 <strong>MongoDB</strong> 存储，因为日志是类 JSON 的半结构化数据，不同模板的字段各不相同，用文档库最自然；</li>
<li><strong>每次状态变更，或者模板里任何字段的变更</strong>（比如机器数量、资源类型），都会触发后端的 diff 逻辑：比较变更前后的单子，把差异记录进 diff 库。</li>
</ul><p>所以在平台上，一个单子从创建到交付，每个字段被谁、在什么时候、从什么改成了什么，都能看到。</p><p>diff 的思路大致是这样（示例）：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>type</span><span> </span><span>Change</span><span> </span><span>struct</span><span> {</span></div></div><div><div><div>2</div></div><div><span>  </span><span>Field</span><span> </span><span>string</span><span> </span><span>`bson:"field"`</span></div></div><div><div><div>3</div></div><div><span>  </span><span>From</span><span>  </span><span>any</span><span>    </span><span>`bson:"from"`</span></div></div><div><div><div>4</div></div><div><span>  </span><span>To</span><span>    </span><span>any</span><span>    </span><span>`bson:"to"`</span></div></div><div><div><div>5</div></div><div><span>}</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span>type</span><span> </span><span>DiffLog</span><span> </span><span>struct</span><span> {</span></div></div><div><div><div>8</div></div><div><span>  </span><span>RequestID</span><span> </span><span>int64</span><span>     </span><span>`bson:"request_id"`</span></div></div><div><div><div>9</div></div><div><span>  </span><span>Operator</span><span>  </span><span>string</span><span>    </span><span>`bson:"operator"`</span></div></div><div><div><div>10</div></div><div><span>  </span><span>Kind</span><span>      </span><span>string</span><span>    </span><span>`bson:"kind"`</span><span>    </span><span>// "state" 状态变更 / "field" 字段变更</span></div></div><div><div><div>11</div></div><div><span>  </span><span>Changes</span><span>   []</span><span>Change</span><span>  </span><span>`bson:"changes"`</span></div></div><div><div><div>12</div></div><div><span>  </span><span>Comment</span><span>   </span><span>string</span><span>    </span><span>`bson:"comment"`</span><span> </span><span>// 驳回原因等</span></div></div><div><div><div>13</div></div><div><span>  </span><span>CreatedAt</span><span> </span><span>time</span><span>.</span><span>Time</span><span> </span><span>`bson:"created_at"`</span></div></div><div><div><div>14</div></div><div><span>}</span></div></div><div><div><div>15</div></div><div>
</div></div><div><div><div>16</div></div><div><span>// 把单子转成 map 后逐个字段比较，嵌套的模板字段用点号路径表示，比如 "spec.machine_count"</span></div></div><div><div><div>17</div></div><div><span>func</span><span> </span><span>diff</span><span><span>(</span><span>before</span><span>, </span><span>after</span><span> </span></span><span>map</span><span>[</span><span>string</span><span>]</span><span>any</span><span><span>, </span><span>prefix</span><span> </span></span><span>string</span><span>) []</span><span>Change</span><span> {</span></div></div><div><div><div>18</div></div><div><span>  </span><span>var</span><span> </span><span>out</span><span> []</span><span>Change</span></div></div><div><div><div>19</div></div><div><span>  </span><span>for</span><span> </span><span>k</span><span>, </span><span>a</span><span><span> </span><span>:=</span><span> </span></span><span>range</span><span> </span><span>after</span><span> {</span></div></div><div><div><div>20</div></div><div><span>    </span><span>key</span><span><span> </span><span>:=</span><span> </span></span><span>prefix</span><span><span> </span><span>+</span><span> </span></span><span>k</span></div></div><div><div><div>21</div></div><div><span>    </span><span>b</span><span>, </span><span>ok</span><span><span> </span><span>:=</span><span> </span></span><span>before</span><span>[</span><span>k</span><span>]</span></div></div><div><div><div>22</div></div><div><span>    </span><span>bm</span><span>, </span><span>bIsMap</span><span><span> </span><span>:=</span><span> </span></span><span>b</span><span>.(</span><span>map</span><span>[</span><span>string</span><span>]</span><span>any</span><span>)</span></div></div><div><div><div>23</div></div><div><span>    </span><span>am</span><span>, </span><span>aIsMap</span><span><span> </span><span>:=</span><span> </span></span><span>a</span><span>.(</span><span>map</span><span>[</span><span>string</span><span>]</span><span>any</span><span>)</span></div></div><div><div><div>24</div></div><div><span>    </span><span>switch</span><span> {</span></div></div><div><div><div>25</div></div><div><span>    </span><span>case</span><span> </span><span>ok</span><span><span> </span><span>&amp;&amp;</span><span> </span></span><span>bIsMap</span><span><span> </span><span>&amp;&amp;</span><span> </span></span><span>aIsMap</span><span>:</span></div></div><div><div><div>26</div></div><div><span>      </span><span>out</span><span><span> </span><span>=</span><span> </span></span><span>append</span><span>(</span><span>out</span><span>, </span><span>diff</span><span>(</span><span>bm</span><span>, </span><span>am</span><span>, </span><span>key</span><span>+</span><span>"."</span><span>)...)</span></div></div><div><div><div>27</div></div><div><span>    </span><span>case</span><span><span> </span><span>!</span></span><span>ok</span><span><span> </span><span>||</span><span> </span><span>!</span></span><span>reflect</span><span>.</span><span>DeepEqual</span><span>(</span><span>b</span><span>, </span><span>a</span><span>):</span></div></div><div><div><div>28</div></div><div><span>      </span><span>out</span><span><span> </span><span>=</span><span> </span></span><span>append</span><span>(</span><span>out</span><span>, </span><span>Change</span><span>{</span><span>Field</span><span>: </span><span>key</span><span>, </span><span>From</span><span>: </span><span>b</span><span>, </span><span>To</span><span>: </span><span>a</span><span>})</span></div></div><div><div><div>29</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>30</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>31</div></div><div><span>  </span><span>for</span><span> </span><span>k</span><span>, </span><span>b</span><span><span> </span><span>:=</span><span> </span></span><span>range</span><span> </span><span>before</span><span> {</span></div></div><div><div><div>32</div></div><div><span>    </span><span>if</span><span> </span><span>_</span><span>, </span><span>ok</span><span><span> </span><span>:=</span><span> </span></span><span>after</span><span>[</span><span>k</span><span><span>]; </span><span>!</span></span><span>ok</span><span> {</span></div></div><div><div><div>33</div></div><div><span>      </span><span>out</span><span><span> </span><span>=</span><span> </span></span><span>append</span><span>(</span><span>out</span><span>, </span><span>Change</span><span>{</span><span>Field</span><span>: </span><span>prefix</span><span><span> </span><span>+</span><span> </span></span><span>k</span><span>, </span><span>From</span><span>: </span><span>b</span><span>, </span><span>To</span><span>: </span><span>nil</span><span>})</span></div></div><div><div><div>34</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>35</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>36</div></div><div><span>  </span><span>return</span><span> </span><span>out</span></div></div><div><div><div>37</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>前端的流程图也是建立在这份数据上的：<strong>只查询 <code>kind = state</code> 的记录，按时间排列，就是流转记录</strong>；节点详情里的审批意见、驳回原因、交付明细，也都来自这里。一份数据，同时支撑了「流程图」和「完整变更历史」两个视图。</p></section><section><h3>3. 父子任务与自动结单<a href="#3-父子任务与自动结单"><span>#</span></a></h3><p>一个需求往往同时包含机器、IP、带宽，由不同的建设平台负责，进度各不相同。所以评估通过后会拆成多个<strong>子任务</strong>分别跟踪，父任务是需求单本身。</p><p>收尾不靠人工确认，而是<strong>定时任务</strong>校验：所有子任务都已交付时，自动把父任务变更为已交付，完成结单。</p></section><section><h3>4. 和各资源建设平台对接：状态对齐与轮询<a href="#4-和各资源建设平台对接状态对齐与轮询"><span>#</span></a></h3><p>各资源建设平台有自己的流程和状态定义，所以对接的第一步是<strong>和每个平台对齐状态枚举</strong>：它们的哪些状态对应我们的「建设中」，哪些算「已交付」，哪些算失败。</p><p>进度同步靠轮询，因为各平台不一定支持回调。轮询频率不是统一的，而是<strong>按资源类型分别和各个平台协商</strong>，根据对方接口能承受的压力配置成各自的定时任务。这一点很重要：轮询方是在消耗别人的资源，频率应该由双方商量，而不是自己说了算。</p></section><section><h3>5. 失败处理：操作事务化与回滚<a href="#5-失败处理操作事务化与回滚"><span>#</span></a></h3><p>一次操作可能包含多个步骤，比如评估通过后，要把多个子任务分别下发到不同的建设平台。如果其中一个失败了，单子就会处于「部分下发」的尴尬状态。</p><p>当时的做法是<strong>把一次操作事务化</strong>：流程中任何一个子项失败，整个操作回滚，单子回到操作前的状态；失败和回滚都会发消息通知相关的人，处理完问题后重新操作即可。这样保证了单子的状态永远是一致的，不会出现一半成功、一半失败。</p></section></section>
<section><h2>四、回头看：做对了什么<a href="#四回头看做对了什么"><span>#</span></a></h2><ul>
<li><strong>先解决业务问题</strong>。平台最大的价值不在技术，而是把一个靠人推动的流程，变成了规范、透明、可追溯的线上流程。</li>
<li><strong>审计做成了通用能力</strong>。diff 机制不关心具体是哪个字段，新增模板字段不用改审计逻辑，就自动有了变更记录。</li>
<li><strong>结单自动化</strong>。父子任务加定时校验，减少了人工跟进和遗漏。</li>
<li><strong>尊重下游</strong>。状态枚举对齐、轮询频率协商，让对接方愿意配合，这比技术本身更难。</li>
</ul></section>
<section><h2>五、回头看：如果重来会改什么<a href="#五回头看如果重来会改什么"><span>#</span></a></h2><section><h3>1. 状态机和业务规则解耦<a href="#1-状态机和业务规则解耦"><span>#</span></a></h3><p>当时最大的问题是：状态流转是按业务需求一点点实现的。某个节点只能到某个节点、某些节点不能驳回、某类资源由哪些团队评估，这些规则散落在状态处理的代码里，和流转本身绑在一起。规则越加越多，改规则就要改代码、发版本，一个状态能去哪里也要把好几处代码拼起来才看得清。</p><p>现在会把「当前状态 + 事件 → 目标状态」抽成一张<strong>转移表</strong>，状态机核心只负责查表，业务规则通过 Guard（流转前校验）和 Action（流转后动作）挂上去：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>type</span><span> </span><span>Transition</span><span> </span><span>struct</span><span> {</span></div></div><div><div><div>2</div></div><div><span>  </span><span>From</span><span>   </span><span>State</span></div></div><div><div><div>3</div></div><div><span>  </span><span>Event</span><span>  </span><span>Event</span></div></div><div><div><div>4</div></div><div><span>  </span><span>To</span><span>     </span><span>State</span></div></div><div><div><div>5</div></div><div><span>  </span><span>Guard</span><span>  </span><span>func</span><span><span>(</span><span>*</span></span><span>Ctx</span><span>) </span><span>error</span><span> </span><span>// 返回 error 则拒绝，比如操作人不是该资源类型的评估人</span></div></div><div><div><div>6</div></div><div><span>  </span><span>Action</span><span> </span><span>func</span><span><span>(</span><span>*</span></span><span>Ctx</span><span>) </span><span>error</span><span> </span><span>// 流转后的动作，比如拆分并下发子任务</span></div></div><div><div><div>7</div></div><div><span>}</span></div></div><div><div><div>8</div></div><div>
</div></div><div><div><div>9</div></div><div><span>var</span><span> </span><span>transitions</span><span><span> </span><span>=</span><span> []</span></span><span>Transition</span><span>{</span></div></div><div><div><div>10</div></div><div><span><span>  </span></span><span>{</span><span>From</span><span>: </span><span>Draft</span><span>, </span><span>Event</span><span>: </span><span>Submit</span><span>, </span><span>To</span><span>: </span><span>Reviewing</span><span>, </span><span>Guard</span><span>: </span><span>requireTemplateComplete</span><span>},</span></div></div><div><div><div>11</div></div><div><span><span>  </span></span><span>{</span><span>From</span><span>: </span><span>Reviewing</span><span>, </span><span>Event</span><span>: </span><span>Approve</span><span>, </span><span>To</span><span>: </span><span>Evaluating</span><span>, </span><span>Guard</span><span>: </span><span>requireReviewer</span><span>},</span></div></div><div><div><div>12</div></div><div><span><span>  </span></span><span>{</span><span>From</span><span>: </span><span>Reviewing</span><span>, </span><span>Event</span><span>: </span><span>Reject</span><span>, </span><span>To</span><span>: </span><span>Rejected</span><span>, </span><span>Guard</span><span>: </span><span>requireReviewerAndReason</span><span>},</span></div></div><div><div><div>13</div></div><div><span><span>  </span></span><span>{</span><span>From</span><span>: </span><span>Evaluating</span><span>, </span><span>Event</span><span>: </span><span>Approve</span><span>, </span><span>To</span><span>: </span><span>Building</span><span>, </span><span>Guard</span><span>: </span><span>requireEvaluator</span><span>, </span><span>Action</span><span>: </span><span>dispatchSubTasks</span><span>},</span></div></div><div><div><div>14</div></div><div><span><span>  </span></span><span>{</span><span>From</span><span>: </span><span>Evaluating</span><span>, </span><span>Event</span><span>: </span><span>Reject</span><span>, </span><span>To</span><span>: </span><span>Rejected</span><span>, </span><span>Guard</span><span>: </span><span>requireEvaluatorAndReason</span><span>},</span></div></div><div><div><div>15</div></div><div><span><span>  </span></span><span>{</span><span>From</span><span>: </span><span>Rejected</span><span>, </span><span>Event</span><span>: </span><span>Submit</span><span>, </span><span>To</span><span>: </span><span>Reviewing</span><span>, </span><span>Guard</span><span>: </span><span>requireApplicant</span><span>},</span></div></div><div><div><div>16</div></div><div><span><span>  </span></span><span>{</span><span>From</span><span>: </span><span>Building</span><span>, </span><span>Event</span><span>: </span><span>Deliver</span><span>, </span><span>To</span><span>: </span><span>Delivered</span><span>, </span><span>Guard</span><span>: </span><span>requireAllSubTasksDone</span><span>},</span></div></div><div><div><div>17</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>「某个节点只能到某些节点」就是表里有没有这一行，「某个节点不能驳回」就是这个状态没有 <code>Reject</code> 这一行。变化最频繁的「哪类资源由哪些团队评估」，则放到配置表里由 Guard 读取，调整团队不用发版本：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>- </span><span>resource_type</span><span>: </span><span>bandwidth</span></div></div><div><div><div>2</div></div><div><span>  </span><span>evaluators</span><span>: [</span><span>network-team</span><span>, </span><span>cost-team</span><span>]</span></div></div><div><div><div>3</div></div><div><span>- </span><span>resource_type</span><span>: </span><span>physical_machine</span></div></div><div><div><div>4</div></div><div><span>  </span><span>evaluators</span><span>: [</span><span>server-team</span><span>, </span><span>cost-team</span><span>]</span></div></div><div><div><div>5</div></div><div><span>- </span><span>resource_type</span><span>: </span><span>ip</span></div></div><div><div><div>6</div></div><div><span>  </span><span>evaluators</span><span>: [</span><span>network-team</span><span>]</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h3>2. 区分两类副作用<a href="#2-区分两类副作用"><span>#</span></a></h3><p>当时把一次操作整体事务化、失败就回滚，保证了一致性。回头看，副作用其实分两类，可以分开处理：</p><ul>
<li><strong>决定业务结果的</strong>，比如下发建设任务：失败了单子就不该推进，回滚是对的。但要注意，回滚数据库很容易，已经在外部平台创建成功的任务却回滚不了，需要调用对方的取消接口做<strong>补偿</strong>，这就是 Saga 模式的思路。所以下发前最好先做预校验，下发接口本身也要幂等，避免重试时重复建单。</li>
<li><strong>不影响业务结果的</strong>，比如发飞书通知：不应该因为通知发失败就回滚整个审批。更好的做法是<strong>事务性发件箱（Outbox）</strong>：在同一个事务里把「待发送的通知」写进一张表，事务提交后由后台任务发送，失败重试。</li>
</ul><div><div><div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>失败则重试</p></span></div><div><span><p>审批操作</p></span></div><div><span><p>事务：更新状态 + 写 outbox</p></span></div><div><span><p>outbox 表</p></span></div><div><span><p>后台任务</p></span></div><div><span><p>发送飞书通知</p></span></div>
</div><div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>失败则重试</p></span></div><div><span><p>审批操作</p></span></div><div><span><p>事务：更新状态 + 写 outbox</p></span></div><div><span><p>outbox 表</p></span></div><div><span><p>后台任务</p></span></div><div><span><p>发送飞书通知</p></span></div>
</div></div></div></section><section><h3>3. 为什么没用现成的工作流引擎<a href="#3-为什么没用现成的工作流引擎"><span>#</span></a></h3><p>Java 生态有 Camunda、Flowable 这类 BPMN 引擎，Go 生态有 Temporal 这样的编排框架，也有 <code>looplab/fsm</code>、<code>qmuntal/stateless</code> 这类状态机库。在这个场景下，自己实现是合理的：</p><ul>
<li>流程本身不复杂，状态不多，引入完整的工作流引擎，运维和学习成本可能比收益还大；</li>
<li>审批规则和公司内部的组织架构、资源平台深度绑定，引擎能省掉的只是很薄的状态流转那一层；</li>
<li>后端只有我一个人负责，一个轻量、完全掌控的实现更容易维护。</li>
</ul><p>但如果流程变得很复杂，比如有并行会签、超时自动升级、需要业务人员自己拖拽配置流程，现成的引擎就值得考虑了。选型的关键不是「自研好还是开源好」，而是<strong>复杂度在哪一层</strong>。</p></section></section>
<section><h2>六、总结<a href="#六总结"><span>#</span></a></h2><p>这个平台的状态机，是从业务里一点点长出来的。它在一致性（乐观锁、事务化回滚）和可追溯（通用 diff 审计）上做得比较扎实，这两点也正是审批类系统最核心的要求。最大的遗憾是状态流转和业务规则耦合在一起。如果重来，第一件事就是把稳定的状态机核心和易变的业务规则分开：前者用转移表一次写好，后者交给配置。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://tracerzzz.cn/posts/metric-anomaly-attribution/</id>
      <title type="text">指标异动归因：从贡献度公式到 CDN 毛利率的层层下钻</title>
      <published>2026-10-25T06:00:00.000Z</published>
      <updated>2026-10-25T06:00:00.000Z</updated>
      <author><name>tracerZzz</name></author>
      <link rel="alternate" href="https://tracerzzz.cn/posts/metric-anomaly-attribution/"/>
      <summary type="text">整理我学习指标异动归因的过程：加法、乘法、比率三类指标分别怎么算贡献度，为什么比率指标要拆出「结构变化」，以及我在字节火山 CDN 经营分析中，如何用这套方法对毛利率、收入、成本做逐层递归归因，把收入变化拆到客户增减、用量变化和价格变化。</summary>
      <content type="html"><![CDATA[<p>在字节做火山 CDN 经营分析时，看板每天都会出数，领导最常问的不是「毛利率是多少」，而是「<strong>毛利率为什么掉了？</strong>」。一张看板只能告诉你结果，回答「为什么」得靠归因。</p>
<p>这篇文章记录我当时学习归因分析的过程，以及最后落地到经营分析平台上的做法。方法主要参考了 CSDN 上一篇讲贡献度计算的文章（见文末参考），这里按我自己的理解重新梳理，例子都换成了 CDN 场景。文中数字均为示意，不是真实业务数据。</p>
<section><h2>一、核心思路：顺着公式往下拆<a href="#一核心思路顺着公式往下拆"><span>#</span></a></h2><p>归因的出发点很简单：<strong>每个指标都有计算公式，指标变了，一定是公式里的某个子指标变了。</strong></p><p>所以归因就是两步，反复递归：</p><ol>
<li>把指标按公式拆成子指标；</li>
<li>算出每个子指标对这次变化的<strong>贡献度</strong>，找出贡献最大的那个，再对它继续拆。</li>
</ol><p>难点在第 2 步：公式的形态不同，贡献度的算法也不同。按公式形态，指标可以分成三类。</p></section>
<section><h2>二、加法型指标：直接看差值<a href="#二加法型指标直接看差值"><span>#</span></a></h2><p><strong>形态</strong>：总量 = 各部分之和。比如总成本 = 各客户成本之和，总带宽 = 各域名带宽之和。</p><p><strong>贡献度</strong>：某部分的变化量 ÷ 总量的变化量。</p><span><span><span>Ci=ΔxiΔY,Y=∑ixiC_i = \frac{\Delta x_i}{\Delta Y},\quad Y=\sum_i x_i</span><span><span><span></span><span><span>C</span><span><span><span><span><span><span></span><span><span>i</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span><span></span><span>=</span><span></span></span><span><span></span><span><span></span><span><span><span><span><span><span></span><span><span>Δ</span><span>Y</span></span></span><span><span></span><span></span></span><span><span></span><span><span>Δ</span><span><span>x</span><span><span><span><span><span><span></span><span><span>i</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span><span></span></span><span>,</span><span></span><span></span><span>Y</span><span></span><span>=</span><span></span></span><span><span></span><span><span><span><span><span><span></span><span><span>i</span></span></span><span><span></span><span><span>∑</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span><span></span><span><span>x</span><span><span><span><span><span><span></span><span><span>i</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span></span></span></span></span><p><strong>例子</strong>：本月总成本比上月涨了 100 万，其中客户 A 涨了 80 万，客户 B 涨了 30 万，客户 C 降了 10 万。那么 A 的贡献度是 80%，B 是 30%，C 是 -10%，加起来正好 100%。</p><p>这是最直观的一类，注意贡献度可以是负数，也可以超过 100%（有正有负相互抵消时）。</p></section>
<section><h2>三、乘法型指标：用对数拆开<a href="#三乘法型指标用对数拆开"><span>#</span></a></h2><p><strong>形态</strong>：指标 = 几个因子相乘。比如带宽成本 = 计费带宽 × 单价。</p><p>乘法的麻烦在于因子之间会「交叉影响」：带宽涨了 20%、单价降了 10%，总的变化里有一部分是两者叠加出来的，没法简单归给某一方。</p><p>常用的解法是 <strong>LMDI（对数平均迪氏指数分解）</strong>。思路是取对数，把乘法变成加法：</p><span><span><span>ln⁡Y1Y0=∑iln⁡xi1xi0\ln\frac{Y^1}{Y^0} = \sum_i \ln\frac{x_i^1}{x_i^0}</span><span><span><span></span><span>ln</span><span></span><span><span></span><span><span><span><span><span><span></span><span><span><span>Y</span><span><span><span><span><span><span></span><span><span>0</span></span></span></span></span></span></span></span></span></span><span><span></span><span></span></span><span><span></span><span><span><span>Y</span><span><span><span><span><span><span></span><span><span>1</span></span></span></span></span></span></span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span><span></span></span><span></span><span>=</span><span></span></span><span><span></span><span><span><span><span><span><span></span><span><span>i</span></span></span><span><span></span><span><span>∑</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span><span></span><span>ln</span><span></span><span><span></span><span><span><span><span><span><span></span><span><span><span>x</span><span><span><span><span><span><span></span><span><span>i</span></span></span><span><span></span><span><span>0</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span></span></span><span><span></span><span></span></span><span><span></span><span><span><span>x</span><span><span><span><span><span><span></span><span><span>i</span></span></span><span><span></span><span><span>1</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span><span></span></span></span></span></span></span><p>再乘上一个对数平均权重，把每个因子的贡献换算回绝对值：</p><span><span><span>ΔYxi=L(Y1,Y0)⋅ln⁡xi1xi0,L(a,b)=a−bln⁡a−ln⁡b\Delta Y_{x_i} = L(Y^1, Y^0)\cdot\ln\frac{x_i^1}{x_i^0},\quad L(a,b)=\frac{a-b}{\ln a-\ln b}</span><span><span><span></span><span>Δ</span><span><span>Y</span><span><span><span><span><span><span></span><span><span><span><span>x</span><span><span><span><span><span><span></span><span><span>i</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span><span></span><span>=</span><span></span></span><span><span></span><span>L</span><span>(</span><span><span>Y</span><span><span><span><span><span><span></span><span><span>1</span></span></span></span></span></span></span></span><span>,</span><span></span><span><span>Y</span><span><span><span><span><span><span></span><span><span>0</span></span></span></span></span></span></span></span><span>)</span><span></span><span>⋅</span><span></span></span><span><span></span><span>ln</span><span></span><span><span></span><span><span><span><span><span><span></span><span><span><span>x</span><span><span><span><span><span><span></span><span><span>i</span></span></span><span><span></span><span><span>0</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span></span></span><span><span></span><span></span></span><span><span></span><span><span><span>x</span><span><span><span><span><span><span></span><span><span>i</span></span></span><span><span></span><span><span>1</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span><span></span></span><span>,</span><span></span><span></span><span>L</span><span>(</span><span>a</span><span>,</span><span></span><span>b</span><span>)</span><span></span><span>=</span><span></span></span><span><span></span><span><span></span><span><span><span><span><span><span></span><span><span>ln</span><span></span><span>a</span><span></span><span>−</span><span></span><span>ln</span><span></span><span>b</span></span></span><span><span></span><span></span></span><span><span></span><span><span>a</span><span></span><span>−</span><span></span><span>b</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span><span></span></span></span></span></span></span><p>这种分解没有残差，各因子贡献加起来正好等于总变化。</p><p><strong>例子</strong>：上月计费带宽 1000、单价 10，成本 10000；本月带宽 1200、单价 9，成本 10800，涨了 800。</p><ul>
<li>对数平均权重 L ≈ 10395</li>
<li>带宽的贡献 ≈ 10395 × ln(1.2) ≈ <strong>+1895</strong></li>
<li>单价的贡献 ≈ 10395 × ln(0.9) ≈ <strong>-1095</strong></li>
<li>合计 = 800 ✓</li>
</ul><p>结论很清楚：成本上涨是用量增长带来的，降价抵消了一部分。</p></section>
<section><h2>四、比率型指标：要分清「自己变了」还是「结构变了」<a href="#四比率型指标要分清自己变了还是结构变了"><span>#</span></a></h2><p>这是最容易算错、也最有价值的一类。</p><p><strong>形态</strong>：指标 = 分子 / 分母。比如毛利率、命中率、单位带宽成本。整体比率可以写成各组比率按分母占比的加权平均：</p><span><span><span>r=∑iri pi,pi=第 i 组分母占比r = \sum_i r_i\, p_i,\quad p_i=\text{第 } i \text{ 组分母占比}</span><span><span><span></span><span>r</span><span></span><span>=</span><span></span></span><span><span></span><span><span><span><span><span><span></span><span><span>i</span></span></span><span><span></span><span><span>∑</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span><span></span><span><span>r</span><span><span><span><span><span><span></span><span><span>i</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span><span></span><span><span>p</span><span><span><span><span><span><span></span><span><span>i</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span><span>,</span><span></span><span></span><span><span>p</span><span><span><span><span><span><span></span><span><span>i</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span><span></span><span>=</span><span></span></span><span><span></span><span><span>第</span><span> </span></span><span>i</span><span><span> </span><span>组分母占比</span></span></span></span></span></span><p>整体比率变化，可能有两种原因：</p><ul>
<li><strong>组内变化</strong>：某个客户自己的毛利率变了；</li>
<li><strong>结构变化</strong>：各组本身没变，但低毛利客户的收入占比变大了，整体也会被拉低。</li>
</ul><p>如果只看各客户自身的毛利率，会完全漏掉第二种原因。</p><section><h3>三种算法<a href="#三种算法"><span>#</span></a></h3><p><strong>1. 控制变量法</strong>：只让一组变化，其他组保持基期，看整体比率变了多少。直观，但各组贡献加起来通常不等于总变化，不推荐。</p><p><strong>2. 综合占比法</strong>：把变化拆成「组内比率变化」和「占比变化」两部分。各组贡献可以相加，等于总变化。</p><p><strong>3. 综合贡献法</strong>（推荐）：在综合占比法基础上，再把结构项改成「该组比率与整体比率的差距 × 占比变化」：</p><span><span><span>Δr=∑i[Δri pi0⏟组内变化+Δri Δpi⏟交叉项+(ri0−r0) Δpi⏟结构变化]\Delta r = \sum_i \Big[\underbrace{\Delta r_i\, p_i^0}_{\text{组内变化}} + \underbrace{\Delta r_i\, \Delta p_i}_{\text{交叉项}} + \underbrace{(r_i^0 - r^0)\,\Delta p_i}_{\text{结构变化}}\Big]</span><span><span><span></span><span>Δ</span><span>r</span><span></span><span>=</span><span></span></span><span><span></span><span><span><span><span><span><span></span><span><span>i</span></span></span><span><span></span><span><span>∑</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span><span></span><span><span>[</span></span><span></span><span><span><span><span><span><span></span><span><span><span><span>组内变化</span></span></span></span></span><span><span></span><span><span><span><span><span><span></span><span><span></span><span></span><span></span></span></span><span><span></span><span><span>Δ</span><span><span>r</span><span><span><span><span><span><span></span><span><span>i</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span><span></span><span><span>p</span><span><span><span><span><span><span></span><span><span>i</span></span></span><span><span></span><span><span>0</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span><span></span><span>+</span><span></span></span><span><span></span><span><span><span><span><span><span></span><span><span><span><span>交叉项</span></span></span></span></span><span><span></span><span><span><span><span><span><span></span><span><span></span><span></span><span></span></span></span><span><span></span><span><span>Δ</span><span><span>r</span><span><span><span><span><span><span></span><span><span>i</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span><span></span><span>Δ</span><span><span>p</span><span><span><span><span><span><span></span><span><span>i</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span><span></span><span>+</span><span></span></span><span><span></span><span><span><span><span><span><span></span><span><span><span><span>结构变化</span></span></span></span></span><span><span></span><span><span><span><span><span><span></span><span><span></span><span></span><span></span></span></span><span><span></span><span><span>(</span><span><span>r</span><span><span><span><span><span><span></span><span><span>i</span></span></span><span><span></span><span><span>0</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span><span></span><span>−</span><span></span><span><span>r</span><span><span><span><span><span><span></span><span><span>0</span></span></span></span></span></span></span></span><span>)</span><span></span><span>Δ</span><span><span>p</span><span><span><span><span><span><span></span><span><span>i</span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span></span></span><span>​</span></span><span><span><span></span></span></span></span></span><span></span><span><span>]</span></span></span></span></span></span><p>推导的关键一步是：各组占比之和恒为 1，所以占比变化之和为 0，可以在结构项里减去一个 r⁰ × ΣΔpᵢ 而不改变总和。这样改写之后，结构项的含义变成了：<strong>一个比平均水平差的组占比变大，才会拉低整体</strong>，解释起来顺多了。</p></section><section><h3>例子：毛利率掉了 3 个点<a href="#例子毛利率掉了-3-个点"><span>#</span></a></h3>

<table><thead><tr><th>客户</th><th>上月收入占比</th><th>上月毛利率</th><th>本月收入占比</th><th>本月毛利率</th></tr></thead><tbody><tr><td>A</td><td>60%</td><td>40%</td><td>50%</td><td>40%</td></tr><tr><td>B</td><td>40%</td><td>20%</td><td>50%</td><td>18%</td></tr><tr><td>整体</td><td></td><td>32%</td><td></td><td>29%</td></tr></tbody></table><p>按综合贡献法拆：</p>

<table><thead><tr><th>客户</th><th>组内变化</th><th>交叉项</th><th>结构变化</th><th>合计</th></tr></thead><tbody><tr><td>A</td><td>0</td><td>0</td><td>-0.8 pp</td><td>-0.8 pp</td></tr><tr><td>B</td><td>-0.8 pp</td><td>-0.2 pp</td><td>-1.2 pp</td><td>-2.2 pp</td></tr><tr><td>合计</td><td>-0.8 pp</td><td>-0.2 pp</td><td>-2.0 pp</td><td><strong>-3.0 pp</strong></td></tr></tbody></table><p>结论：毛利率下降的 3 个点里，有 2 个点来自<strong>结构变化</strong>，也就是低毛利的客户 B 收入占比上升了；只有 1 个点是 B 自身毛利率下降。如果只盯着各客户的毛利率，会以为 A 完全没问题、B 只降了 2 个点，得出错误的结论。</p></section></section>
<section><h2>五、在字节的应用：按指标公式逐层递归<a href="#五在字节的应用按指标公式逐层递归"><span>#</span></a></h2><p>在火山 CDN 经营分析平台上，我把上面三类方法组合成了一套递归归因的逻辑。</p><section><h3>1. 先建好指标公式树<a href="#1-先建好指标公式树"><span>#</span></a></h3><p>经营分析的核心指标之间，本来就有明确的公式关系。把它们整理成一棵树，每个节点标明公式类型：</p><ul>
<li><strong>毛利率</strong>（比率）= 毛利 / 收入，可以按客户拆成加权平均</li>
<li><strong>收入</strong>（加法）= 新增客户收入 + 存量客户收入（流失客户记为负）
<ul>
<li><strong>客户收入</strong>（乘法）= 计费用量 × 平均单价</li>
<li><strong>平均单价</strong>（比率）= 各计费项、各区域单价按用量占比加权</li>
</ul>
</li>
<li><strong>成本</strong>（加法）= 各客户成本之和 = 各域名成本之和
<ul>
<li><strong>客户成本</strong>（乘法）≈ 计费用量 × 单位成本</li>
</ul>
</li>
</ul><p>其中成本数据来自我设计的「95 区间」分摊算法，每天产出到客户、域名粒度，这是整套归因能落地的前提：没有细粒度的成本，就拆不下去。</p></section><section><h3>2. 收入归因：客户、用量、价格<a href="#2-收入归因客户用量价格"><span>#</span></a></h3><p>收入是经营分析里被问得最多的指标，也是拆得最细的一棵子树。它的难点在于，同一个「收入变化」背后可能是完全不同的业务动作：新签了客户、老客户放量、续约降价、流量往海外迁移，这些对应的跟进人和应对方式都不一样。所以收入归因要分三层拆。</p><p><strong>第一层：客户增减（加法）</strong></p><p>先把收入变化按客户类型分开：</p><ul>
<li><strong>新增客户</strong>：上期没有、本期有收入，全部算作新增贡献；</li>
<li><strong>流失客户</strong>：上期有、本期没有，全部算作流失贡献（负数）；</li>
<li><strong>存量客户</strong>：两期都有收入，进入下一层继续拆。</li>
</ul><p>例如本月收入环比 +50 万：新增客户 +30 万，流失客户 -40 万，存量客户 +60 万。单看总数是小幅增长，拆开才发现流失其实很严重，只是被存量增长盖住了。</p><p><strong>第二层：用量 × 单价（乘法，LMDI）</strong></p><p>存量客户的收入 = 计费用量 × 平均单价，用前面的 LMDI 拆成「用量变化」和「价格变化」两部分。</p><p>例如头部客户 C，上月收入 200 万，本月用量 +20%，续约后单价 -10%，收入变成 216 万：</p><ul>
<li>对数平均权重 L ≈ 207.9</li>
<li>用量贡献 ≈ 207.9 × ln(1.2) ≈ <strong>+37.9 万</strong></li>
<li>价格贡献 ≈ 207.9 × ln(0.9) ≈ <strong>-21.9 万</strong></li>
<li>合计 = +16 万 ✓</li>
</ul><p>这个结论对销售和运营很有用：C 的业务在增长，但降价吃掉了一半以上的增量。</p><p><strong>第三层：单价变化的「真调价」与「结构变化」（比率）</strong></p><p>拆到价格还没完。CDN 的平均单价本身是一个比率：不同计费区域（国内、海外各区域）、不同计费项的单价差别很大，平均单价 = 各项单价按用量占比加权。所以平均单价变了，可能根本没有调价，只是用量结构变了。</p><p>例如某客户国内单价 10、海外单价 30，两个月都没调价：</p>

<table><thead><tr><th>区域</th><th>上月用量占比</th><th>本月用量占比</th><th>单价</th></tr></thead><tbody><tr><td>国内</td><td>90%</td><td>80%</td><td>10</td></tr><tr><td>海外</td><td>10%</td><td>20%</td><td>30</td></tr><tr><td>平均单价</td><td></td><td></td><td>12 → 14</td></tr></tbody></table><p>用综合贡献法拆，组内变化（真调价）为 0，平均单价上涨的 2 全部来自结构变化：海外占比上升贡献 +1.8，国内占比下降贡献 +0.2。也就是说，这个客户的「涨价」其实是流量往海外迁移了。如果把它当成调价成功去汇报，就是错误的结论。</p><p>到这里，一次收入异动就能落到具体的业务动作上：是哪类客户、哪个客户，是用量还是价格，价格是真调价还是区域结构变化。</p></section><section><h3>3. 递归下钻<a href="#3-递归下钻"><span>#</span></a></h3><p>当某个指标出现异动（比如环比超过阈值），就从这个节点开始：</p><ol>
<li>根据节点的公式类型，选择对应的贡献度算法；</li>
<li>算出各子节点的贡献度，取贡献最大的那个；</li>
<li>对这个子节点重复上面的过程，直到叶子节点，或者贡献度已经足够分散、无法再明显收敛。</li>
</ol><p>最后输出一条归因路径，例如：</p><blockquote><p>毛利率环比 -3pp → 主要来自结构变化（低毛利客户收入占比上升）→ 客户 B → 成本上涨 → 计费用量增长贡献最大 → 域名 x</p></blockquote><blockquote><p>收入环比 +50 万 → 存量客户 +60 万、流失客户 -40 万 → 存量中客户 C 贡献最大 → 用量 +37.9 万、价格 -21.9 万 → 价格下降来自续约调价</p></blockquote><p>这样，看板上看到的不再只是一个变红的数字，而是一句可以直接拿去跟进的结论。</p></section><section><h3>4. 实践里的几点体会<a href="#4-实践里的几点体会"><span>#</span></a></h3><ul>
<li><strong>公式要和口径完全一致</strong>：归因拆出来的子指标，必须和看板上的计算口径一致，否则各部分加起来对不上总数，业务方第一眼就不信。</li>
<li><strong>比率指标一定要看结构</strong>：CDN 业务里客户结构变化非常常见，比如某个大客户放量、低价客户占比上升，只看组内变化很容易误判。</li>
<li><strong>新增和流失要单独处理</strong>：新客户上期为 0、流失客户本期为 0，取对数会出错，LMDI 也算不了，必须在乘法拆解之前先用加法把它们拆出去。</li>
<li><strong>递归要有停止条件</strong>：贡献度太分散时，继续下钻只会得到噪音；这时输出「多因素共同影响」比强行给出一个结论更可靠。</li>
<li><strong>归因只是定位，不是解释</strong>：它能告诉你「是 B 客户的用量涨了」，但「为什么涨」还需要运营去跟客户确认。</li>
</ul></section></section>
<section><h2>参考<a href="#参考"><span>#</span></a></h2><ul>
<li><a href="https://damodev.csdn.net/68243c99a5baf817cf4bc552.html" target="_blank">指标归因贡献度计算方法（加法 / 乘法 / 除法型）- CSDN</a></li>
<li>Ang, B.W. (2005). The LMDI approach to decomposition analysis: a practical guide. <em>Energy Policy</em>, 33(7), 867–871.</li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://tracerzzz.cn/posts/cdn-bandwidth-cost-allocation-patent/</id>
      <title type="text">我的第一项专利：用「95 区间」把 CDN 带宽成本分得更准</title>
      <published>2026-10-25T05:00:00.000Z</published>
      <updated>2026-10-25T05:00:00.000Z</updated>
      <author><name>tracerZzz</name></author>
      <link rel="alternate" href="https://tracerzzz.cn/posts/cdn-bandwidth-cost-allocation-patent/"/>
      <summary type="text">白话解读我作为第一发明人的发明专利（中国 CN117061354B、美国 US12375742B2）：为什么按 95 峰值时间点分摊 CDN 带宽成本会算不准，以及「95 区间」分摊法是怎么解决的。附专利原文 PDF。</summary>
      <content type="html"><![CDATA[<p>这是我在字节跳动做 CDN 成本分摊时设计的一套算法，后来申请了专利，并在中国和美国都拿到了授权。我是第一发明人。这篇文章不讲法律文本，用大白话说清楚它解决了什么问题、是怎么做的。</p>
<section><h2>专利信息<a href="#专利信息"><span>#</span></a></h2>

<table><thead><tr><th></th><th>中国</th><th>美国</th></tr></thead><tbody><tr><td>名称</td><td>CDN中的信息处理方法、系统、设备和存储介质</td><td>Method and system for processing information in CDN, device, and storage medium</td></tr><tr><td>专利号</td><td>CN117061354B</td><td>US12375742B2</td></tr><tr><td>申请日</td><td>2023-10-10</td><td>2024-10-10</td></tr><tr><td>授权日</td><td>2024-01-12</td><td>2025-07-29</td></tr><tr><td>专利权人</td><td>北京火山引擎科技有限公司</td><td>Beijing Volcano Engine Technology Co., Ltd.</td></tr><tr><td>原文</td><td><a href="/files/patents/CN117061354B.pdf">下载 PDF</a></td><td><a href="/files/patents/US20250119603A1.pdf">下载公开文本 PDF</a> · <a href="https://image-ppubs.uspto.gov/dirsearch-public/print/downloadPdf/12375742" target="_blank">USPTO 授权全文</a></td></tr></tbody></table></section>
<section><h2>一、问题：一份带宽账单，怎么分给很多用户？<a href="#一问题一份带宽账单怎么分给很多用户"><span>#</span></a></h2><p>CDN 厂商（或者一家公司内部采购 CDN 的平台）买的是一整份带宽，再给很多用户、很多域名共享着用。月底付给上游的带宽费用，业内最常见的计费方式是 <strong>95 峰值</strong>：</p><blockquote><p>一个月里每 5 分钟采一个总带宽点，30 天共 8640 个点。从大到小排序，去掉最高的 5%（432 个点），剩下的最大值，也就是第 433 个点，就是「95 带宽值」，按它计费。</p></blockquote><p>钱是按总带宽付的，接下来要回答的问题是：<strong>这笔钱里，每个用户该承担多少？</strong> 这就是成本分摊，它直接决定了每个客户的毛利、每个内部业务该被收多少钱。</p></section>
<section><h2>二、传统做法：只看 95 那一个时间点<a href="#二传统做法只看-95-那一个时间点"><span>#</span></a></h2><p>常见的做法很直接：找到 95 带宽值对应的那个时间点（「95 时间点」），看每个用户在这个时间点用了多少带宽，按比例分。</p><p>比如 95 带宽值是 300，用户 A 在 95 时间点用了 100，那 A 就承担三分之一的成本。</p><p>简单，但有两个硬伤。</p><p><strong>1. 只看一个点，不能代表整个月。</strong> 假设 95 时间点落在第 15 天，用户 A 那一刻恰好没用带宽，但第 16 到 25 天用得很猛。按这种算法，A 一分钱不用分摊，可实际上正是 A 这些天的高用量把整体带宽和成本推高了。</p><p><strong>2. 选哪个点，结果就不一样。</strong> 总带宽曲线一天里通常有早晚两个高峰，一条水平线（95 带宽值）会和曲线交出好几个点，这几个点的总带宽都等于 95 值。随便挑一个，分摊结果可能天差地别：在 a 点 A 没用带宽，不用分摊；换成 b 点 A 用了，就要分摊。同一份账单，结果取决于运气。</p></section>
<section><h2>三、我的做法：把「一个点」换成「一段区间」<a href="#三我的做法把一个点换成一段区间"><span>#</span></a></h2><p>核心想法一句话：<strong>不看单个时间点，而是看 95 值附近的一段带宽区间里，各用户整体用了多少。</strong></p><p>具体分四步：</p><ol>
<li><strong>采样排序</strong>：取管理周期（比如一个月）内每个采样点的总带宽值，按时间排好。</li>
<li><strong>划出管理区间</strong>：在 95 带宽值附近定一个下界 A 和一个上界 B（比如 95 值上下各取一个总带宽值），总带宽曲线落在 A 到 B 之间的那些时间段，就是「管理区间」。一个月里通常会有很多段。</li>
<li><strong>算占比</strong>：统计每个用户在所有管理区间里用掉的带宽，除以全部用户在这些区间里用掉的总带宽，得到这个用户的使用占比。</li>
<li><strong>按占比分摊</strong>：成本按这个占比分给各个用户。</li>
</ol><p>下面是专利中的图 7，说明管理区间是怎么划出来的：</p><p></p><figure><img alt="专利图 7：在 95 带宽值上下取阈值 A、B，总带宽曲线落在两条虚线之间的时间段 a1–a2、b1–b2、c1–c2、d1–d2 就是管理区间" loading="lazy" width="959" height="545" src="/_astro/fig7-management-interval.mS-7XHW6_Z2pfm9E.webp" /><figcaption>专利图 7：在 95 带宽值上下取阈值 A、B，总带宽曲线落在两条虚线之间的时间段 a1–a2、b1–b2、c1–c2、d1–d2 就是管理区间</figcaption></figure><p></p><p>95 带宽值是图中的实线，A、B 是它下方和上方的两个阈值。总带宽曲线每次穿过 A、B 之间这条带，就形成一段管理区间。一天有早晚两个高峰，每个高峰的上坡和下坡各穿过一次，所以这里有 a1–a2、b1–b2、c1–c2、d1–d2 四段；一个月下来，就是几十上百段。</p><p>第 3 步有两种算法，可以按需选择：</p><ul>
<li><strong>累加法</strong>：直接把区间内各采样点的带宽值加起来。用户 A 的占比 = A 在区间内各点带宽之和 ÷ 区间内各点总带宽之和。计算简单，省算力，工程上最常用。</li>
<li><strong>积分法</strong>：把总带宽和每个用户的带宽分别拟合成曲线，用区间内曲线下的面积来算占比。精度更高。</li>
</ul><p>专利中的图 6 把积分法画得很直观：</p><p></p><figure><img alt="专利图 6：用户 A、B、C 的带宽依次叠加成三条曲线，竖条是管理区间，每条竖条里不同灰度的面积就是各用户在区间内的用量" loading="lazy" width="1218" height="710" src="/_astro/fig6-area-share.Bn8DGAao_1rPF1Q.webp" /><figcaption>专利图 6：用户 A、B、C 的带宽依次叠加成三条曲线，竖条是管理区间，每条竖条里不同灰度的面积就是各用户在区间内的用量</figcaption></figure><p></p><ul>
<li>最下面的曲线是用户 A 的带宽；中间是 A 和 B 叠加；最上面是 A、B、C 三者叠加，也就是总带宽；</li>
<li>顶部横着的浅灰色带，就是 95 值附近的阈值带，竖向的四根条带就是管理区间；</li>
<li>在每根条带里，从下往上三种灰度，分别是 A、B、C 在这段时间里的用量（曲线下的面积）；</li>
<li>某个用户的使用占比 = 他在所有条带里的面积之和 ÷ 所有条带的总面积。</li>
</ul><p>这就像微积分里用面积来累计一段时间内的量：看的不是某一个瞬间谁用得多，而是<strong>整段高峰期里，每个人贡献了多少</strong>。</p><p>另外还有一个约束：区间内的采样点数量，占整个周期采样点的比例不能低于某个阈值。区间太窄、点太少，结果又会退化成「只看几个点」。</p></section>
<section><h2>四、为什么这样更准<a href="#四为什么这样更准"><span>#</span></a></h2><p>回到前面的两个例子：</p><ul>
<li>用户 A 在 95 时间点没用带宽，但在区间里其他时间点用了：传统做法判定 A 不用分摊，区间法会把 A 的用量算进去。</li>
<li>有好几个点都等于 95 值：区间法本来就把这些点都包含在内，不存在「挑哪个点」的问题，结果是稳定的。</li>
</ul><p>还有一个实际业务中很常见、单点法处理不好的情况：<strong>按流量计费的客户，以及月中才接入的客户。</strong></p><p>CDN 厂商和上游运营商结算带宽，通常是按所有客户叠加后的总带宽取 95 值。但下游客户的计费方式各不相同：有的客户和我们按带宽结算，有的按流量结算。按流量计费的客户，他付给我们的钱和 95 无关，可他的带宽一样叠加在总带宽曲线上，一样抬高了我们付给运营商的 95 值，理应分摊这部分成本。</p><p>单点法在这里很容易失真：</p><ul>
<li>一个月中才接入的客户，如果 95 时间点落在他接入之前，他在那一刻的带宽是 0，就一分钱成本都分不到；</li>
<li>按流量计费的客户用量往往不规律，95 时间点恰好赶上他的低谷，分到的成本也会远低于实际。</li>
</ul><p>但他们在月内的其他高峰期，确实贡献了相当一部分 95 值以上的打点。区间法覆盖的是整个月里所有的高峰区间，只要客户在这些时段有用量，就会按比例分到成本。这样算出来的客户毛利更真实，能减少「看起来很赚钱、其实在亏钱」的误判。</p><p>同时，区间是围绕 95 值划定的，<strong>保留了 95 计费的业务含义</strong>：分摊的依据依然是决定账单的那段高峰期用量，而不是全天的平均用量。</p><p>简单说，它既延续了 95 计费的逻辑，又解决了单点分摊不准、不稳的问题，对各种计费方式、各种接入时间的客户都能给出合理的成本。</p></section>
<section><h2>五、落地情况<a href="#五落地情况"><span>#</span></a></h2><p>这套算法最早是我在字节做火山 CDN 经营分析时设计的。验证的方式很朴素：和数据工程团队一起，用全量客户的计费打点数据，把几种分摊方式都跑一遍，在 Grafana 上并排对比结果，用数据说服团队采用。</p><p>后来它的使用范围不断扩大：</p><ul>
<li><strong>火山 CDN 经营分析</strong>：每天定时产出客户、域名维度的成本，进一步算出收入和毛利率。</li>
<li><strong>融合 CDN 内部结算</strong>：字节内部各业务（头条、抖音点播直播等）按这套方法分摊二三十家 CDN 厂商的采购成本。</li>
<li><strong>火山多云 CDN 的 FinOps 能力</strong>：作为商用功能，帮客户把多家厂商的账单拆到域名。</li>
</ul><p>数据团队后续的带宽成本分摊，也都基于这套算法实现。</p></section>
<section><h2>六、原文下载<a href="#六原文下载"><span>#</span></a></h2><ul>
<li>中国发明专利 CN117061354B：<a href="/files/patents/CN117061354B.pdf">PDF</a></li>
<li>美国专利公开文本 US 2025/0119603 A1：<a href="/files/patents/US20250119603A1.pdf">PDF</a></li>
<li>美国授权专利 US12375742B2：<a href="https://image-ppubs.uspto.gov/dirsearch-public/print/downloadPdf/12375742" target="_blank">USPTO 全文</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://tracerzzz.cn/posts/hexo-to-astro-migration/</id>
      <title type="text">从 Hexo 迁移到 Astro：图片找回、旧链接、欢迎页重写与双站点部署</title>
      <published>2026-10-23T02:30:00.000Z</published>
      <updated>2026-10-30T17:00:00.000Z</updated>
      <author><name>tracerZzz</name></author>
      <link rel="alternate" href="https://tracerzzz.cn/posts/hexo-to-astro-migration/"/>
      <summary type="text">记录把 2016 年搭的 Hexo 博客迁移到 Astro 的全过程：从七牛找回图片、转换文章、保留旧链接、重写 SVG 动画欢迎页、搭建自动检查，部署到阿里云 ESA 和 GitHub Pages，以及从 Retypeset 换到 Firefly 主题，中间踩过的坑和背后的原理。</summary>
      <content type="html"><![CDATA[<p>这个博客 2016 年用 Hexo 搭起来，写到 2018 年就基本停了。这几年它悄悄坏掉了：图床域名过期，所有文章的图片都打不开；本地的 Hexo 环境早就没了，想改点东西都得先折腾半天。</p>
<p>这次干脆整体迁到 <a href="https://astro.build/" target="_blank">Astro</a>，顺便把这些年欠下的问题一起解决。这篇文章记录整个过程，以及每一步背后的原理，方便以后自己回头查。</p>
<section><h2>一、原来的状况<a href="#一原来的状况"><span>#</span></a></h2><ul>
<li><strong>框架</strong>：Hexo + NexT 主题，源文件在 <code>hexoWorkspace</code> 仓库，生成的静态网站推到 <code>tracerZzz.github.io</code> 仓库，由 GitHub Pages 托管。</li>
<li><strong>写作</strong>：Typora 写 Markdown，图片用 PicGo 上传到七牛云，文章里引用七牛的外链。</li>
<li><strong>域名</strong>：博客用 <code>tracerzzz.cn</code>（已备案），图片用的是另一个域名，<strong>已经过期</strong>。</li>
<li><strong>首页</strong>：一个用 SVG + GSAP 补间动画做的欢迎页，点击里面的按钮才进入文章列表。动画代码当年是用 webpack 手动打包压缩的。</li>
</ul><p>问题很明显：<strong>文章和图片分在两个地方，图床一挂，文章就废了一半</strong>。</p></section>
<section><h2>二、目标和选型<a href="#二目标和选型"><span>#</span></a></h2><ol>
<li><strong>图片跟着文章走</strong>：图片和 Markdown 放在一起，存进同一个 git 仓库。不再依赖任何图床。</li>
<li><strong>旧链接不能断</strong>：以前分享出去的文章地址要能继续打开。</li>
<li><strong>欢迎页保留</strong>：但要改成”改了源码就能自动打包”的形式，不再手动 webpack。</li>
<li><strong>主站放国内，再留一个备用</strong>：主站用阿里云 ESA（边缘安全加速）的 Pages，国内访问快；GitHub Pages 作为备用站点。</li>
</ol><p>框架选 Astro。主题一开始用的是 <a href="https://github.com/radishzzz/astro-theme-retypeset" target="_blank">Retypeset</a>：排版干净，以文字为主。上线后觉得太素，文章列表也不能显示图片，又换成了 <a href="https://github.com/CuteLeaf/Firefly" target="_blank">Firefly</a>（基于 Fuwari 二次开发）：卡片式布局，有封面图、首页横幅、分类和侧边栏。换主题的过程见第十二节。</p><p>图片目录沿用 Typora 的习惯：每篇文章旁边一个同名的 <code>.assets</code> 文件夹，文章里用相对路径引用。</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>src/content/posts/git/</span></div></div><div><div><div>2</div></div><div><span>├── git常用命令.md</span></div></div><div><div><div>3</div></div><div><span>└── git常用命令.assets/</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>└── 20180514152628079993927.png</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>这样做的好处是：Typora 粘贴图片时会自动放进这个文件夹；以后换 Obsidian 之类的编辑器，相对路径也照样能用；Astro 构建时会自动把这些图片压缩、转成 webp。</p></section>
<section><h2>三、从七牛找回图片<a href="#三从七牛找回图片"><span>#</span></a></h2><p>图片的域名过期了，但七牛的存储空间还在，文件都在里面，只是没有可用的域名能下载。</p><section><h3>思路：绕过域名，直接用 API 下载<a href="#思路绕过域名直接用-api-下载"><span>#</span></a></h3><p>七牛提供 <strong>S3 兼容接口</strong>，可以像操作 AWS S3 一样，用 AccessKey/SecretKey 签名请求，直接列出和下载空间里的文件，不需要绑定任何域名。</p><p>macOS 自带的 curl 8.x 内置了 AWS 签名功能：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>curl</span><span> </span><span>--aws-sigv4</span><span> </span><span>"aws:amz:cn-east-1:s3"</span><span> </span><span>\</span></div></div><div><div><div>2</div></div><div><span>     </span><span>--user</span><span> </span><span>"</span><span>$AK</span><span>:</span><span>$SK</span><span>"</span><span> </span><span>\</span></div></div><div><div><div>3</div></div><div><span>     </span><span>"https://s3.cn-east-1.qiniucs.com/&lt;空间名&gt;/&lt;文件名&gt;"</span><span> </span><span>-o</span><span> </span><span>文件名</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>写了一个脚本：先列出空间里所有文件，再逐个下载。AK/SK 运行时手动输入，不写进脚本，也不保存。</p></section><section><h3>踩坑 1：签名一直报 <code>SignatureDoesNotMatch</code><a href="#踩坑-1签名一直报-signaturedoesnotmatch"><span>#</span></a></h3><p>所有区域都返回 403 签名错误。原因是<strong>控制台上看到的”空间名称”，不一定是 S3 接口里用的 bucket 名</strong>。先调用 ListBuckets 接口列出真实的 bucket 名，再用它去请求，就好了。</p><p><strong>原理</strong>：AWS SigV4 签名会把请求方法、路径、主机名、时间、区域等信息一起做 HMAC 计算。路径里的 bucket 名写错，服务端算出来的签名就和你的对不上，它不会告诉你”bucket 不存在”，只会说签名不匹配。所以遇到签名错误，先怀疑参数，而不是密钥。</p></section><section><h3>踩坑 2：<code>invalid args</code><a href="#踩坑-2invalid-args"><span>#</span></a></h3><p>改完后又报 <code>invalid args</code>。打印出来一看，AK/SK 两个变量的长度都是 0：在一个终端里 <code>export</code> 的变量，换了窗口就没了。改成脚本里用 <code>read</code> 读取，就解决了。</p></section><section><h3>踩坑 3：macOS 的 sed 不认 <code>\n</code><a href="#踩坑-3macos-的-sed-不认-n"><span>#</span></a></h3><p>解析 S3 返回的 XML 列表时，用 <code>sed</code> 把标签替换成换行，在 Linux 上没问题，在 macOS 上却不生效。macOS 自带的是 BSD sed，替换内容里不支持 <code>\n</code>、<code>\t</code> 这种写法。改用 perl 解析，两边行为一致。</p></section><section><h3>踩坑 4：中文文件名下载失败<a href="#踩坑-4中文文件名下载失败"><span>#</span></a></h3><p>178 个文件里有 6 个下载失败，全是中文文件名。macOS 自带的 bash 还是 3.2 版本，用它写的 URL 编码函数会按字符处理，不会按 UTF-8 的字节处理，中文就编码错了。签名用的路径和实际请求的路径对不上，又是签名错误。同样换成 perl 做 URL 编码，问题解决。</p><p>最后 178 个文件全部下载成功，文章实际用到的几十张图片一张不少。</p></section></section>
<section><h2>四、转换文章<a href="#四转换文章"><span>#</span></a></h2><p>写了一个一次性的 Python 脚本（<code>scripts/migrate-hexo.py</code>），把 Hexo 文章转成 Astro 的格式。</p><section><h3>文章开头的元信息（front matter）<a href="#文章开头的元信息front-matter"><span>#</span></a></h3><p>三套写法对比（Hexo 是原来的，Retypeset 是第一版，Firefly 是现在用的）：</p>

<table><thead><tr><th>Hexo</th><th>Retypeset</th><th>Firefly（现在）</th><th>说明</th></tr></thead><tbody><tr><td><code>title</code></td><td><code>title</code></td><td><code>title</code></td><td>不变</td></tr><tr><td><code>date</code></td><td><code>published</code></td><td><code>published</code></td><td>统一加上 <code>+08:00</code> 时区</td></tr><tr><td>无</td><td><code>updated</code></td><td><code>updated</code></td><td>更新时间，可选</td></tr><tr><td><code>excerpt</code>（里面嵌着 HTML 图片标签）</td><td><code>description</code></td><td><code>description</code></td><td>去掉 HTML，只留纯文字。不写的话主题会从正文截取</td></tr><tr><td><code>tags</code></td><td><code>tags</code></td><td><code>tags</code></td><td>不变</td></tr><tr><td><code>categories</code>（可以多个）</td><td>没有</td><td><code>category</code>（只能一个）</td><td>Retypeset 没有分类；Firefly 有，按文章所在文件夹自动填上</td></tr><tr><td>无</td><td>没有</td><td><code>image</code></td><td>封面图，显示在文章列表里；文章详情页不显示。后来全部不用了，见下面”封面怎么写”</td></tr><tr><td>无</td><td><code>pin: 1</code></td><td><code>pinned: true</code></td><td>置顶</td></tr><tr><td><code>_draft</code> 目录</td><td><code>draft: true</code></td><td><code>draft: true</code></td><td>草稿不会发布</td></tr><tr><td><code>permalink</code> 规则（全局）</td><td><code>abbrlink</code></td><td><code>slug</code></td><td>文章网址，比如 <code>git-commands</code> 对应 <code>/posts/git-commands/</code>。Retypeset 用的是自己的 <code>abbrlink</code> 字段；换到 Firefly 后改用 Astro 自带的 <code>slug</code>，见第十二节</td></tr></tbody></table><p>现在一篇文章开头的完整写法：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>---</span></div></div><div><div><div>2</div></div><div><span>title</span><span>: </span><span>git常用命令</span></div></div><div><div><div>3</div></div><div><span>published</span><span>: </span><span>2018-04-15T16:37:11+08:00</span></div></div><div><div><div>4</div></div><div><span>description</span><span>: </span><span>在开发过程中 git 已经是必不可少的版本控制工具……</span></div></div><div><div><div>5</div></div><div><span>tags</span><span>:</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>- </span><span>git</span></div></div><div><div><div>7</div></div><div><span>category</span><span>: </span><span>Git</span></div></div><div><div><div>8</div></div><div><span>image</span><span>: </span><span>./git常用命令.assets/20180514152628079993927.png</span></div></div><div><div><div>9</div></div><div><span>draft</span><span>: </span><span>false</span></div></div><div><div><div>10</div></div><div><span>slug</span><span>: </span><span>git-commands</span></div></div><div><div><div>11</div></div><div><span>---</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>必填的只有 <code>title</code> 和 <code>published</code>，其他都可以省略。</p></section><section><h3>封面怎么写<a href="#封面怎么写"><span>#</span></a></h3><p>封面要在 <code>image</code> 里手动指定，主题不会自动拿正文里的图：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>image</span><span>: </span><span>./文章名.assets/cover.png</span></div></div></code></pre><div><div></div><div></div></div></figure></div><ul>
<li><strong>路径</strong>：相对这篇 <code>.md</code> 文件，和正文里插图的写法一样。用 Typora 粘贴图片后，把正文里那张图的路径复制过来就行；也可以专门放一张不在正文里的图。</li>
<li><strong>不填或者留空</strong>：这篇没有封面，列表里只显示文字。<code>pnpm new-post</code> 生成的模板默认是 <code>image: ''</code>。主题有”随机封面”功能（<code>image: "api"</code>，从网上的随机图接口取图），没开。</li>
<li><strong>会出现在哪</strong>：文章列表的卡片配图、分享海报、搜索引擎和社交软件的预览图（<code>og:image</code>）。文章详情页<strong>不显示</strong>。</li>
<li><strong>为什么详情页不显示</strong>：Firefly 默认会在文章标题下面再放一张大封面（<code>src/config/coverImageConfig.ts</code> 里的 <code>enableInPost</code>）。迁移时脚本给 11 篇有图的文章填的封面都是正文第一张图，开着这个选项，进文章就会先看到封面、紧接着又是同一张图。所以把 <code>enableInPost</code> 改成了 <code>false</code>。这是主题给用户改的配置项，不用动源码，也不用删文章里的图。以后如果想让某篇文章顶部有大图，就直接把图放在正文开头。</li>
<li><strong>后来决定全部不用封面</strong>（2026-10-31）：试过给两篇新文章画专门的封面插画，但主题的封面框是固定比例的（电脑端列表约 1.5 : 1，手机端网格 2 : 1），图片按填满方式显示，不同比例下总要裁掉一部分，画面里的文字很容易被切掉。最后把所有文章的 <code>image</code> 都删了（10 篇，封面原本都是正文里的第一张图，正文里的图都还在），列表只显示文字；想要配图的文章，把图放在正文开头。<code>pnpm new-post</code> 模板里的 <code>image: ''</code> 保留，以后哪篇确实想要封面，再手动填上。代价是分享海报和社交软件的预览里也就没有图了。</li>
</ul><p>有个小坑：Hexo 的 <code>excerpt</code> 里直接写了没加引号的 HTML，YAML 解析会报错。脚本遇到解析失败时，先去掉 <code>excerpt</code> 再重试。</p></section><section><h3>正文里的图片<a href="#正文里的图片"><span>#</span></a></h3><ol>
<li>正文里的 <code>&lt;img src="..."&gt;</code> 统一转成 Markdown 的 <code>![](...)</code> 写法，Astro 才会处理这些图片（压缩、生成多种尺寸）。</li>
<li>所有指向旧图床的地址，改成 <code>./文章名.assets/文件名</code>，同时把图片复制到对应文件夹。</li>
<li>转换完后检查，确认没有残留的旧域名。</li>
</ol></section><section><h3>文件命名<a href="#文件命名"><span>#</span></a></h3><p>最开始文件名用的是英文（和网址一致），后来改成了文章标题，比如 <code>git常用命令.md</code>。因为<strong>网址由开头的 <code>slug</code> 决定，和文件名、文件夹都无关</strong>（迁移时叫 <code>abbrlink</code>，换主题后改成了 Astro 自带的 <code>slug</code>），文件名用中文在编辑器里更好认，移动文件或改名也不会影响网址。文件夹按主题分，统一用英文。</p><p>文件名里要避开两类字符：</p><ul>
<li><strong>空格</strong>：Markdown 图片链接里的路径不能有空格，有空格就得用 <code>&lt;&gt;</code> 包起来，很容易出错。</li>
<li><strong><code>+</code></strong>：在 URL 的查询参数里，<code>+</code> 会被当成空格解码。开发模式下图片地址正好是通过查询参数传递的。</li>
</ul></section></section>
<section><h2>五、旧链接怎么不失效<a href="#五旧链接怎么不失效"><span>#</span></a></h2><p>Hexo 的网址格式是 <code>/年/月/日/分类/标题/</code>，比如 <code>/2018/04/15/git/git常用命令/</code>。新博客是 <code>/posts/git-commands/</code>。</p><p>静态托管没有服务器可以配置重定向规则（至少不能每个平台都配），所以用了最通用的办法：<strong>在旧地址生成一个只做跳转的页面</strong>。</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;!</span><span>doctype</span><span> </span><span>html</span><span>&gt;</span></div></div><div><div><div>2</div></div><div><span>&lt;</span><span>html</span><span> </span><span>lang</span><span>=</span><span>"zh"</span><span>&gt;&lt;</span><span>head</span><span>&gt;&lt;</span><span>meta</span><span> </span><span>charset</span><span>=</span><span>"utf-8"</span><span>&gt;</span></div></div><div><div><div>3</div></div><div><span>&lt;</span><span>title</span><span>&gt;git常用命令&lt;/</span><span>title</span><span>&gt;</span></div></div><div><div><div>4</div></div><div><span>&lt;</span><span>link</span><span> </span><span>rel</span><span>=</span><span>"canonical"</span><span> </span><span>href</span><span>=</span><span>"/posts/git-commands/"</span><span>&gt;</span></div></div><div><div><div>5</div></div><div><span>&lt;</span><span>meta</span><span> </span><span>name</span><span>=</span><span>"robots"</span><span> </span><span>content</span><span>=</span><span>"noindex"</span><span>&gt;</span></div></div><div><div><div>6</div></div><div><span>&lt;</span><span>meta</span><span> </span><span>http-equiv</span><span>=</span><span>"refresh"</span><span> </span><span>content</span><span>=</span><span>"0; url=/posts/git-commands/"</span><span>&gt;</span></div></div><div><div><div>7</div></div><div><span>&lt;/</span><span>head</span><span>&gt;&lt;</span><span>body</span><span>&gt;&lt;</span><span>p</span><span>&gt;文章已搬到新地址：&lt;</span><span>a</span><span> </span><span>href</span><span>=</span><span>"/posts/git-commands/"</span><span>&gt;git常用命令&lt;/</span><span>a</span><span>&gt;&lt;/</span><span>p</span><span>&gt;&lt;/</span><span>body</span><span>&gt;&lt;/</span><span>html</span><span>&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>这些页面放在 Astro 的 <code>public/</code> 目录里，构建时原样复制。</p><ul>
<li><code>meta refresh</code> 让浏览器立即跳转。</li>
<li><code>canonical</code> 告诉搜索引擎，正式地址是新的那个。</li>
<li><code>noindex</code> 让搜索引擎不要收录这个跳转页。</li>
</ul><p>这个方案和主题、部署平台都无关，换主题、换平台都不用改。</p><p>后来（2026-10-25）整理旧文章时，把一批内容单薄的文章设成了草稿，不再发布。它们的旧地址如果还指向 <code>/posts/...</code>，就会跳到一个不存在的页面，构建检查也会报错。处理方式是：内容被合并到其他文章的，跳转改指向新文章（比如 webbench 压测那篇合并进了 Nginx 那篇）；单纯下线的，跳转改指向归档页 <code>/archive/</code>，页面上说明这篇旧文已下线。</p></section>
<section><h2>六、欢迎页：从手动打包到 Astro 自动打包<a href="#六欢迎页从手动打包到-astro-自动打包"><span>#</span></a></h2><p>原来的欢迎页由几部分组成：一个 316KB 的 HTML，里面塞满了内联 SVG；动画用 GSAP 1.x 的 TimelineMax；还依赖 jQuery、jquery-ui 的滑块、drawsvg 描边插件、randomColor 随机颜色。当年用 webpack 手动打包，每次改都要重新打包一遍。</p><section><h3>改写方式<a href="#改写方式"><span>#</span></a></h3><ul>
<li>新建一个 <code>src/pages/welcome.astro</code>，放在 <code>/welcome/</code>。最初是不套博客主题布局的独立页面，后来为了让音乐不中断，套进了主题的页面框架（见第十二节）。</li>
<li>大幅 SVG 插画拆成组件，放在 <code>src/components/welcome/</code>。</li>
<li>动画逻辑写成 TypeScript，放在页面的 <code>&lt;script&gt;</code> 里。<strong>Astro（底层是 Vite）会自动打包压缩</strong>，以后改完源码直接 <code>pnpm build</code> 就行。</li>
</ul></section><section><h3>为什么没用 GSAP<a href="#为什么没用-gsap"><span>#</span></a></h3><p>当时我的开发环境装不了 npm 依赖，就算装上了，也会和项目锁定的依赖版本对不上。所以干脆自己写了一个两百多行的小补间库（<code>tween.ts</code>），刚好够用：</p><ul>
<li><strong>时间线采用”确定性渲染”</strong>：给定任意时间点 t，从初始状态出发，按开始时间依次计算每个补间在 t 时刻的值。这样拖动进度条到任意位置都能正确显示，循环播放也只是 <code>t % 总时长</code>。</li>
<li><strong>每个补间的起始值预先算好</strong>：等于它开始那一刻，前面所有补间叠加出来的值，相当于 GSAP 在补间第一次渲染时才记录起始值的行为。</li>
<li><strong>SVG 的旋转和缩放以元素中心为原点</strong>：用 <code>getBBox()</code> 求出中心，拼成 <code>translate(x y) rotate(r ox oy) translate(ox oy) scale(s) translate(-ox -oy)</code>，等同于 GSAP 的 <code>transformOrigin: "50% 50%"</code>。</li>
<li><strong>描边动画</strong>：用 <code>stroke-dasharray</code> 从 <code>0 L</code> 过渡到 <code>L 0</code>（L 是路径总长）。</li>
<li><strong>缓动函数</strong>：照着 GSAP 的公式实现，比如 Power3.easeInOut 就是四次方的缓入缓出，Back.easeInOut 带回弹。</li>
<li>jquery-ui 的滑块换成原生的 <code>&lt;input type="range"&gt;</code>，背景圆点的颜色变化直接用 CSS transition。</li>
<li>菜单里的音乐按钮，后来从”跳到网易云主页”一路改成了”全站共用一个播放器、切换页面音乐不断”，过程见第十二节的”音乐”一段。</li>
</ul></section><section><h3>用截图对比来保证还原度<a href="#用截图对比来保证还原度"><span>#</span></a></h3><p>为了确认效果和原来一样，用 Playwright 打开新旧两个页面，用它的虚拟时钟把动画快进到同样的时间点，然后截图对比。这个过程中发现了几个原版里”看起来是这样，实际不是”的细节：</p><ul>
<li><strong>花瓶插画其实没有描边动画</strong>。原代码对它调用了描边函数，但参数里有个 <code>fill: undefined</code>，GSAP 处理时出错，那个补间实际没生效。花瓶的线条其实是填充出来的形状，看到的变色是填充色在变。</li>
<li><strong>右侧中心图案的变色看不出来</strong>。原代码改的是外层 <code>&lt;g&gt;</code> 的 fill，但里面每条 path 自己都设了 fill 属性，会覆盖从父元素继承来的颜色。</li>
<li><strong>背景圆点一直是半透明的</strong>。原代码同时写了 <code>opacity: 1</code> 和 <code>css: {...}</code>。在 GSAP 1.x 里，一旦提供了 <code>css</code> 对象，外层的属性就不再被当作 CSS 处理，所以 <code>opacity: 1</code> 根本没生效，圆点一直保持 CSS 里的 0.22。</li>
</ul><p>这些”bug”都按原样还原了，因为最终要的是看起来一样。</p></section></section>
<section><h2>七、本地环境的小插曲<a href="#七本地环境的小插曲"><span>#</span></a></h2><ul>
<li><strong>不是管理员账号</strong>：这台 Mac 没有管理员权限，Homebrew 找不到现成的安装包，只能从源码编译 Node，结果连 LLVM 一起编译，几个小时都编译不完。改用 nvm 安装 Node：nvm 下载官方编译好的版本，装在用户目录下，不需要管理员权限，几分钟就好。</li>
<li><strong>私有仓库 clone 要登录</strong>：这和 git config 没关系，git config 里的用户名和邮箱只用来给提交署名。在 GitHub 生成一个只授权这个仓库的细粒度访问令牌（Fine-grained token），clone 时当作密码输入，macOS 钥匙串会记住它。</li>
<li><strong><code>Command "dev" not found</code></strong>：在错误的目录里运行了 <code>pnpm dev</code>。那个目录里没有项目的 <code>package.json</code>，pnpm 当然找不到 <code>dev</code> 命令。</li>
</ul></section>
<section><h2>八、开发模式和构建模式不一样<a href="#八开发模式和构建模式不一样"><span>#</span></a></h2><p>本地运行 <code>pnpm dev</code> 时，发现图片有时显示不出来，页面样式也乱了。但运行 <code>pnpm build &amp;&amp; pnpm preview</code> 就一切正常。</p>

<table><thead><tr><th></th><th><code>pnpm dev</code></th><th><code>pnpm build</code> + <code>pnpm preview</code></th></tr></thead><tbody><tr><td>页面</td><td>打开时才生成</td><td>一次性生成到 <code>dist/</code></td></tr><tr><td>图片</td><td>第一次请求时才转 webp，大图要等几秒</td><td>构建时全部转好</td></tr><tr><td>样式</td><td>边扫描边生成（Retypeset 用的 UnoCSS），偶尔会缺</td><td>一次性生成完整</td></tr><tr><td>草稿</td><td><strong>显示</strong></td><td>不显示</td></tr><tr><td>用途</td><td>写作时即时预览</td><td>确认上线后的样子</td></tr></tbody></table><p>图片还带着 <code>loading="lazy"</code>，滚到附近才开始加载。开发模式下，懒加载再加上第一次现场转码，看起来就像图片打不开。遇到这种情况刷新一下就好，线上不会这样。</p></section>
<section><h2>九、自动检查<a href="#九自动检查"><span>#</span></a></h2><p>光看构建成功还不够：图片少了一张、旧链接跳转到不存在的页面、草稿被误发布……这些都不会让构建失败。所以写了 <code>scripts/verify-build.sh</code>，每次构建后自动检查：</p><ul>
<li>每篇已发布的文章都生成了页面；</li>
<li>每篇文章页面里的图片数量，不少于 Markdown 里引用的数量；</li>
<li>页面里不再出现过期的旧图床域名；</li>
<li>草稿没有被发布；</li>
<li>每个旧链接跳转页都在，并且跳转目标存在；</li>
<li>欢迎页存在，菜单链接和打包后的脚本都在。</li>
</ul><p>检查脚本先用”故意弄坏”的情况测试过，确认它真的能发现问题。然后接入 GitHub Actions：每次推送都执行一遍构建和检查。</p><p>一个小细节：检查欢迎页有没有 <code>&lt;script type="module"&gt;</code> 时，第一次检查失败了。原因是 HTML 压缩工具会<strong>调整属性顺序、去掉属性的引号</strong>，构建出来的实际是 <code>&lt;script src=... type=module&gt;</code>。所以检查规则要写得和属性顺序、引号都无关。</p></section>
<section><h2>十、分支整理<a href="#十分支整理"><span>#</span></a></h2><ul>
<li>原来的 <code>master</code> 是 Hexo 生成的旧网站，改名为 <code>hexo-site</code> 留作存档。</li>
<li>新的 Astro 项目从 <code>astro</code> 分支改名为 <code>master</code>，作为默认分支。</li>
</ul><p>默认分支改了以后，GitHub 的 Dependabot 安全提醒（之前报的都是旧 Hexo 的依赖）也随之消失了，因为它只检查默认分支。</p></section>
<section><h2>十一、部署<a href="#十一部署"><span>#</span></a></h2><section><h3>GitHub Pages（备用站）<a href="#github-pages备用站"><span>#</span></a></h3><p><strong>私有仓库用 GitHub Pages 要付费</strong>（GitHub Pro）。博客内容本来就是公开的，所以直接把仓库改成了公开。公开前确认过：仓库里没有密钥，草稿被别人看到也无所谓。</p><p>接着又碰到一个报错：GitHub 自带的 Jekyll 构建失败，报 <code>Invalid YAML front matter in .../NoteIcon.astro</code>。</p><p><strong>原因</strong>：Pages 的来源还是”Deploy from a branch”，GitHub 会把分支内容当成 Jekyll 网站来构建，它把 <code>.astro</code> 文件开头的 <code>---</code> 当成了 YAML 元信息。<strong>解决</strong>：把来源改成”GitHub Actions”，由我们自己的工作流构建 Astro，再用 <code>actions/deploy-pages</code> 发布。</p><p>Custom domain 留空，用默认地址 <code>tracerzzz.github.io</code>。因为一个域名同一时间只能解析到一个地方，<code>tracerzzz.cn</code> 留给主站 ESA。</p></section><section><h3>阿里云 ESA（主站）<a href="#阿里云-esa主站"><span>#</span></a></h3><section><h4>三方的关系<a href="#三方的关系"><span>#</span></a></h4><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>访客输入 tracerzzz.cn</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>│</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>▼</span></div></div><div><div><div>4</div></div><div><span>① 域名注册商（万网）：登记"这个域名由谁来解析"</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>│</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>▼</span></div></div><div><div><div>7</div></div><div><span>② DNS 解析：把域名翻译成服务器地址</span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>│</span></div></div><div><div><div>9</div></div><div><span><span>  </span></span><span>▼</span></div></div><div><div><div>10</div></div><div><span>③ ESA：边缘节点 + Pages 托管网站</span></div></div></code></pre><div><div></div><div></div></div></figure></div><ul>
<li><strong>ESA 站点</strong>是 <code>tracerzzz.cn</code> 在 ESA 里的总管理处：DNS 解析、HTTPS 证书、缓存、安全防护、跳转规则都在这里。</li>
<li><strong>ESA Pages 项目</strong>是网站本身：从 GitHub 拉代码，构建，发布。</li>
<li>关系是：<strong>Pages 产出网站，站点把网站挂到域名上</strong>。</li>
</ul></section><section><h4>NS 接入<a href="#ns-接入"><span>#</span></a></h4><p>接入方式有两种：</p><ul>
<li><strong>CNAME 接入</strong>：DNS 留在原来的服务商，每个子域名手动加一条 CNAME 指向 ESA。</li>
<li><strong>NS 接入</strong>：把域名的 DNS 服务器整体交给 ESA。</li>
</ul><p>选了 NS 接入：在万网把 <code>tracerzzz.cn</code> 的 DNS 服务器改成 ESA 给的两个地址，以后所有解析都在 ESA 里管理。根域名也能直接用，Pages 绑定域名时会自动生成解析记录。</p><p><strong>改完 NS，本机 dig 查到的还是旧的</strong>。分层排查：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span># 直接问 .cn 注册局，不走任何缓存</span></div></div><div><div><div>2</div></div><div><span>dig</span><span> </span><span>NS</span><span> </span><span>tracerzzz.cn</span><span> </span><span>@a.dns.cn</span><span> </span><span>+norec</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>注册局返回的已经是 ESA 的 NS（<code>*.ns.atrustdns.com</code>），TTL 是 86400 秒。也就是说修改已经生效，只是各级 DNS 的缓存最长要 24 小时才会过期。ESA 验证时查的是权威结果，所以不用等缓存，可以直接验证通过。</p></section><section><h4>Pages 构建失败两次<a href="#pages-构建失败两次"><span>#</span></a></h4><ol>
<li><strong><code>Unknown command: "build"</code></strong>：控制台里填了 <code>npm build</code>，npm 没有这个命令，应该是 <code>npm run build</code>。而且项目用的是 pnpm，安装和构建都应该用 pnpm。ESA 的构建环境里没有预装 pnpm，所以安装命令要写成 <code>npm install -g pnpm &amp;&amp; pnpm install</code>：先用 npm 把 pnpm 装上，再用 pnpm 按锁文件安装依赖。</li>
<li><strong><code>Both assets and function js file are not found</code></strong>：构建其实成功了，但没告诉 ESA 发布哪个目录。</li>
</ol><p>为了不再依赖控制台设置，在仓库根目录加了 <code>esa.jsonc</code>：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>{</span></div></div><div><div><div>2</div></div><div><span>  </span><span>"installCommand"</span><span>: </span><span>"npm install -g pnpm &amp;&amp; pnpm install"</span><span>,</span></div></div><div><div><div>3</div></div><div><span>  </span><span>"buildCommand"</span><span>: </span><span>"pnpm build"</span><span>,</span></div></div><div><div><div>4</div></div><div><span>  </span><span>"assets"</span><span>: {</span></div></div><div><div><div>5</div></div><div><span>    </span><span>"directory"</span><span>: </span><span>"./dist"</span><span>,</span></div></div><div><div><div>6</div></div><div><span>    </span><span>"notFoundStrategy"</span><span>: </span><span>"404Page"</span></div></div><div><div><div>7</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>8</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>有了这个文件，ESA 会优先用它，控制台的设置就不起作用了。配置跟着代码走，修改也有记录可查。</p></section><section><h4>绑定域名：为什么 DNS 记录列表是空的<a href="#绑定域名为什么-dns-记录列表是空的"><span>#</span></a></h4><p>在 Pages 项目里绑定 <code>tracerzzz.cn</code> 后，站点的 DNS 记录列表里却显示 0 条。原来 Pages 绑定的域名不会显示成普通的 A 或 CNAME 记录，而是单独列在”边缘函数记录”里。</p><p>背后的原理：</p><ol>
<li>NS 已经交给 ESA，所以任何人查询 <code>tracerzzz.cn</code>，最终都会问到 ESA 的 DNS。</li>
<li>ESA 的 DNS 发现这个域名绑定了 Pages，就<strong>返回离访客最近的边缘节点 IP</strong>。</li>
<li>浏览器连到这个节点，请求里带着 <code>Host: tracerzzz.cn</code>。节点根据它找到对应的 Pages 项目，再去找当前部署版本里的文件。比如请求 <code>/posts/git-commands/</code>，就返回 <code>dist/posts/git-commands/index.html</code>。</li>
<li>文件第一次被请求时从 ESA 的存储读取，之后缓存在节点上。</li>
</ol><p>整个过程中没有一台属于我自己的服务器。</p></section><section><h4>根域名和 www<a href="#根域名和-www"><span>#</span></a></h4><p><code>tracerzzz.cn</code>（根域名，主机记录 <code>@</code>）和 <code>www.tracerzzz.cn</code>（子域名，主机记录 <code>www</code>）在 DNS 里是两个独立的域名，解析、证书都要分别配置。博客的正式地址是不带 www 的那个，<code>www</code> 也绑到同一个 Pages 项目上，两个地址打开的是同一个网站。本来打算再加一条 301 规则把 <code>www</code> 跳到根域名，后来觉得没必要：文章里的 canonical 链接和 RSS 写的都是根域名，搜索引擎会按根域名收录，<code>www</code> 能打开就够了。</p></section><section><h4>HTTPS<a href="#https"><span>#</span></a></h4><p>ESA 免费版自带 Let’s Encrypt 免费证书，自动续期。申请到的是泛域名证书，证书里登记的域名（SAN）同时包含 <code>*.tracerzzz.cn</code> 和 <code>tracerzzz.cn</code>。这一点要确认：<strong>泛域名 <code>*.tracerzzz.cn</code> 本身不包含根域名</strong>，<code>*</code> 只匹配”某个前缀 + 点”，有些平台签发时不会自动带上根域名。</p><p>还开了两个选项：</p><ul>
<li><strong>强制 HTTPS</strong>：http 请求用 301 跳转到 https。</li>
<li><strong>OCSP Stapling</strong>：浏览器要确认证书没被吊销，不开的话得自己去问证书颁发机构。开了以后，ESA 提前准备好”证书有效”的证明，随证书一起发给浏览器，首次连接更快。</li>
</ul><p>HSTS 暂时没开：它会让浏览器在设定的时长内只认 HTTPS，一旦配置有误很难撤回，等稳定运行一段时间再说。</p></section></section></section>
<section><h2>十二、换主题：从 Retypeset 到 Firefly<a href="#十二换主题从-retypeset-到-firefly"><span>#</span></a></h2><section><h3>为什么能换得这么轻松<a href="#为什么能换得这么轻松"><span>#</span></a></h3><p>前面的结构从一开始就和主题无关：文章和图片在 <code>src/content/posts/</code>，旧链接跳转页在 <code>public/</code>，欢迎页是独立页面，ESA 配置和检查脚本也和主题没关系。真正要处理的只有两件事：<strong>文章开头的字段名</strong>，和<strong>文章网址的生成规则</strong>。</p><p>换主题时没有直接在 <code>master</code> 上改，而是开了一个 <code>theme-firefly</code> 分支：把 Firefly 的代码放进去，再把文章、图片、跳转页、欢迎页、ESA 配置、检查脚本搬回来。GitHub Actions 会构建这个分支、跑检查，但不发布；本地 <code>pnpm dev</code> 看满意了，再合并到 <code>master</code>。</p></section><section><h3>保持网址不变：从 <code>abbrlink</code> 换成 <code>slug</code><a href="#保持网址不变从-abbrlink-换成-slug"><span>#</span></a></h3><p>Firefly 用文件路径生成文章网址，比如 <code>git/git常用命令.md</code> 会变成 <code>/posts/git/git常用命令/</code>。直接这样用的话，所有网址都会变，旧链接跳转页也全部失效。</p><p><strong>第一版做法</strong>：沿用 Retypeset 时代的 <code>abbrlink</code> 字段，在内容集合里自定义文章 ID：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>loader</span><span>: </span></span><span>glob</span><span>({</span></div></div><div><div><div>2</div></div><div><span>  </span><span>pattern</span><span>:</span><span> </span><span>"**/*.{md,mdx}"</span><span>,</span></div></div><div><div><div>3</div></div><div><span>  </span><span>base</span><span>:</span><span> </span><span>"./src/content/posts"</span><span>,</span></div></div><div><div><div>4</div></div><div><span>  </span><span>// 有 abbrlink 就用 abbrlink 当 ID，没有才用文件路径</span></div></div><div><div><div>5</div></div><div><span>  </span><span>generateId</span><span>:</span><span><span> ({ </span><span>entry</span><span>, </span><span>data</span><span> }) </span></span><span>=&gt;</span></div></div><div><div><div>6</div></div><div><span>    </span><span>typeof</span><span><span> </span><span>data</span><span>.</span></span><span>abbrlink</span><span> </span><span>===</span><span> </span><span>"string"</span><span> </span><span>&amp;&amp;</span><span><span> </span><span>data</span><span>.</span></span><span>abbrlink</span></div></div><div><div><div>7</div></div><div><span>      </span><span>?</span><span><span> </span><span>data</span><span>.</span></span><span>abbrlink</span></div></div><div><div><div>8</div></div><div><span>      </span><span>:</span><span><span> </span><span>entry</span><span>.</span></span><span>replace</span><span>(</span><span><span>/</span><span>\.</span><span>(md</span><span>|</span><span>mdx)</span></span><span>$</span><span>/</span><span>i</span><span>, </span><span>""</span><span>),</span></div></div><div><div><div>9</div></div><div><span>}),</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>主题里所有生成文章链接的地方用的都是这个 ID，所以网址都变回了 <code>/posts/git-commands/</code>。能用，但这是改了主题的代码，以后升级主题时，这几行可能会被覆盖，或者和新版本冲突。</p><p><strong>现在的做法</strong>：后来发现根本不用改代码。Astro 的 <code>glob</code> 加载器本身就有这个规则：文章开头写了 <code>slug</code>，就用它当 ID；没写，才用文件路径。Firefly 自带的 <code>pnpm new-post</code> 生成的也是 <code>slug</code> 字段。于是把所有文章的 <code>abbrlink</code> 改名为 <code>slug</code>，再删掉上面那段自定义代码，<code>content.config.ts</code> 就和主题原版一模一样了。网址一个没变。</p><p><code>slug</code> 只用小写字母、数字和连字符，不要带 <code>/</code>，带了网址里就会多出一层目录。</p></section><section><h3>分类和封面<a href="#分类和封面"><span>#</span></a></h3><ul>
<li><strong>分类</strong>：用脚本给每篇文章补上 <code>category</code>，值按所在文件夹来，比如 <code>git/</code> 对应”Git”，<code>database/</code> 对应”数据库”。</li>
<li><strong>封面</strong>：用正文里的第一张图（后来发现详情页会重复显示，关掉了详情页的封面，见第四节”封面怎么写”）。这里又踩了一次”代码里的示例被当成真图片”的坑：迁移这篇文章里有一句举例用的 <code>![](./文章名.assets/...)</code>，被脚本当成了封面，构建时找不到这个文件。改成跳过代码块里的内容就好了。</li>
</ul></section><section><h3>进出欢迎页时的残影<a href="#进出欢迎页时的残影"><span>#</span></a></h3><p>Firefly 用 swup 做页面过渡：切换页面时不整页刷新，而是只替换页面里几块固定的区域（横幅、正文、侧边栏），再配上淡入淡出。欢迎页是独立页面，没有这几块区域，所以进出欢迎页时只换掉了一部分，剩下的就成了残影。</p><p>解决办法是让 swup 跳过欢迎页：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>swup</span><span>({</span></div></div><div><div><div>2</div></div><div><span>  </span><span>// ...</span></div></div><div><div><div>3</div></div><div><span>  </span><span>ignore</span><span>:</span><span><span> (</span><span>url</span><span>) </span></span><span>=&gt;</span></div></div><div><div><div>4</div></div><div><span>    </span><span>new</span><span> </span><span>URL</span><span><span>(</span><span>url</span><span>, </span><span>window</span><span>.</span></span><span>location</span><span>.</span><span>href</span><span>).</span><span>pathname</span><span>.</span><span>startsWith</span><span>(</span><span>"/welcome"</span><span>) </span><span>||</span></div></div><div><div><div>5</div></div><div><span><span>    </span></span><span>window</span><span>.</span><span>location</span><span>.</span><span>pathname</span><span>.</span><span>startsWith</span><span>(</span><span>"/welcome"</span><span>),</span></div></div><div><div><div>6</div></div><div><span>}),</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>目标是欢迎页，或者当前就在欢迎页，都按普通方式整页跳转；博客内部各页面之间照样有过渡动画。</p><blockquote><p>后来为了让音乐在进出欢迎页时不中断，把欢迎页套进了博客的页面框架，残影的根源没了，这条规则也随之删掉，见下面”音乐”一段。</p></blockquote></section><section><h3>分页后横幅位置不一样<a href="#分页后横幅位置不一样"><span>#</span></a></h3><p>文章列表翻到第二页，横幅的壁纸取景和第一页不一样。原因是 Firefly 把横幅分成两种：首页的横幅更高、带大标题；其他页面的横幅会整体上移一截，留出更多空间给正文。主题只把 <code>/</code> 当首页，第二页的地址是 <code>/2/</code>，被当成了普通页面。</p><p>把分页地址（<code>/2/</code>、<code>/3/</code>……）也算作首页就好了。这个判断主题里有三处：生成页面时一处，浏览器里 swup 切换页面时两处。三处都要改，否则直接打开第二页正常，从第一页点过去又会不一样。</p></section><section><h3>备用站上的分享链接还是主站域名<a href="#备用站上的分享链接还是主站域名"><span>#</span></a></h3><p>在 <code>tracerzzz.github.io</code> 上点分享，复制出来的却是 <code>tracerzzz.cn</code> 的地址。原因是网站是同一份代码，两边各构建一次，构建时文章的完整地址都按配置里的主域名写进了页面。</p><p>修法是在浏览器里打开页面时，把分享用的地址换成当前访问的域名：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>function</span><span> </span><span>shareUrl</span><span>()</span><span>:</span><span> </span><span>string</span><span> {</span></div></div><div><div><div>2</div></div><div><span>  </span><span>const</span><span> </span><span>u</span><span> </span><span>=</span><span> </span><span>new</span><span> </span><span>URL</span><span><span>(</span><span>url</span><span>, </span><span>window</span><span>.</span></span><span>location</span><span>.</span><span>href</span><span>);</span></div></div><div><div><div>3</div></div><div><span>  </span><span>return</span><span><span> </span><span>window</span><span>.</span></span><span>location</span><span>.</span><span>origin</span><span> </span><span>+</span><span><span> </span><span>u</span><span>.</span></span><span>pathname</span><span> </span><span>+</span><span><span> </span><span>u</span><span>.</span></span><span>search</span><span> </span><span>+</span><span><span> </span><span>u</span><span>.</span></span><span>hash</span><span>;</span></div></div><div><div><div>4</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>一共三处：分享弹窗里的”复制链接”、分享海报上的二维码、文章底部版权信息里的”本文链接”。</p><p>页面里给搜索引擎看的”正式地址”（<code>&lt;link rel="canonical"&gt;</code>）和 RSS 里的链接<strong>故意没改</strong>，还是指向 <code>tracerzzz.cn</code>。两个站点内容完全一样，这样搜索引擎只会收录主站，不会把备用站当成重复内容。</p></section><section><h3>音乐：从”跳到网易云主页”到”全站切换不断播”<a href="#音乐从跳到网易云主页到全站切换不断播"><span>#</span></a></h3><p>这一块前后改了六版，每一版都是被上一版的问题逼出来的。</p><p><strong>1. 原来的链接是死链。</strong> 欢迎页的音乐按钮链接到我的网易云个人主页。用没登录的浏览器打开，直接是 404。给别人看等于没有。</p><p><strong>2. 网易云外链播放器 + 弹窗。</strong> 网易云提供官方的外链播放器（<code>music.163.com/outchain/player?type=0&amp;id=歌单ID</code>），不用登录就能播放公开歌单。先做成点击音乐图标弹出一个窗口，关闭窗口时把播放器整个移除，音乐也就停了。问题是弹窗一直挡在页面中间，不友好。外链播放器还放不了 VIP 歌曲，歌单里只有免费的歌能完整播放。</p><p><strong>3. 改成悬停浮出。</strong> 鼠标移到音乐图标上，旁边浮出一个小播放器；移开后隐藏，音乐继续放。几个细节：</p><ul>
<li>鼠标移开后延迟 0.4 秒再隐藏，否则从图标移向播放器的途中就消失了。</li>
<li>隐藏用 <code>visibility: hidden</code>，不用 <code>display: none</code>，也不移除 iframe，保证声音不断。</li>
<li>手机上没有悬停，改成点一下显示、再点一下隐藏。</li>
<li>浮窗做成半透明的毛玻璃（<code>backdrop-filter: blur()</code>），边框换成很淡的白线。中间那条播放条的底色是网易云页面决定的，跨域改不了。</li>
</ul><p><strong>4. 想显示”正在播放”，碰上了同源策略。</strong> 播放器在 iframe 里，地址是 <code>music.163.com</code>，外面的欢迎页是 <code>tracerzzz.cn</code>。浏览器的同源策略禁止一个页面读取另一个域名页面的内容，我们拿不到它的播放状态、当前歌曲，也收不到播放和暂停事件。跨域页面之间唯一正规的通信方式是 <code>postMessage</code>，但这需要网易云在播放器里主动发消息，它没有提供这样的接口。</p><p>不过网易云播放器的完整版自带当前歌曲和歌单列表。用了一个小技巧来加”播放列表”按钮：始终嵌入完整版，平时用外框裁到只露出顶部的播放条；点按钮时把外框拉高，露出下面的歌单。因为一直是同一个 iframe，展开、收起时音乐不会中断。</p><p><strong>5. 博客里也要能听，而且要和欢迎页共享进度。</strong> Firefly 自带一个音乐播放器，能通过 Meting 接口读取网易云歌单，在导航栏和侧边栏播放。Meting 是第三方搭的接口，主题里配了几个备用地址，一个失效会自动换下一个；VIP 歌曲会被接口跳过。只改了配置文件（歌单 ID 和开关），没改主题代码。博客页面之间切换时音乐不中断，因为 swup 只替换正文，播放器所在的部分一直保留着。</p><p>欢迎页的外链播放器在跨域的 iframe 里，读不到进度，没法和博客共享，所以欢迎页也改用了同一个播放器（主题的 <code>MusicManager</code>），另做了一个迷你界面：封面、歌名、进度条、上一首 / 播放 / 下一首、可展开的歌单。界面通过主题播放器公开的 <code>window.__fireflyMusic</code> 接口控制，监听它发出的 <code>fm:track</code>、<code>fm:time</code> 等事件更新显示。</p><p>但欢迎页当时还是独立页面，进出欢迎页都是整页刷新，音乐会断。于是加了 <code>music-resume.ts</code>：离开页面（<code>pagehide</code>）前把”第几首、播到第几秒、是否在播放”存进 <code>sessionStorage</code>，新页面的播放器就绪后跳到同一首、同一位置继续。这里有个时序坑：主题播放器初始化时先发 <code>fm:init</code> 事件、再加载第一首，如果在 <code>fm:init</code> 里马上恢复，会被它紧接着的”加载第一首”覆盖，所以要晚一拍再恢复。</p><p><strong>6. 还是会卡一下，最后让欢迎页”不换页”。</strong> 整页跳转时，浏览器会把旧页面连同正在播放的音频整个销毁，新页面要重新加载、缓冲、跳转进度，中间总会停一两秒。这个停顿任何技巧都绕不开，想真正无缝只能不换页。考虑过几种办法：</p>

<table><thead><tr><th>办法</th><th>能否无缝</th><th>问题</th></tr></thead><tbody><tr><td>弹出一个独立的播放器小窗口（<code>window.open</code>）</td><td>能</td><td>体验老派，可能被拦截弹窗，手机上不好用</td></tr><tr><td>预渲染（Speculation Rules）+ 缓存音频地址 + 淡入</td><td>不能，只是停顿变短</td><td>只有 Chrome 系浏览器支持预渲染</td></tr><tr><td>把欢迎页拆进主题模板</td><td>能</td><td>要改主题代码，升级麻烦</td></tr><tr><td><strong>把欢迎页整个套进博客的页面框架</strong></td><td><strong>能</strong></td><td>改动都在自己的文件里</td></tr></tbody></table><p>最后选了最后一种：</p><ul>
<li><code>welcome.astro</code> 改成和”关于”页一样用主题的 <code>MainGridLayout</code>，原来的欢迎页内容不拆，整体放进一个铺满全屏、盖在最上层的图层 <code>#welcome-root</code> 里。播放器在页面框架里，swup 切换时只换内容区域，播放器不销毁，音乐完全不断。</li>
<li>残影的根源（欢迎页没有 swup 需要的容器）也随之消失，前面那条”欢迎页不走 swup”的规则删掉了，对主题配置的修改反而少了一处。</li>
<li>swup 切换时不会重新执行页面脚本，所以欢迎页的动画改成了 <code>mountWelcome()</code>：进入时启动，返回一个清理函数；离开时停掉时间线和定时器，并用 <code>AbortController</code> 一次性移除挂在 <code>window</code>、<code>document</code> 上的监听。由一个注入到每个页面的 <code>controller.ts</code> 负责调用：新内容里出现 <code>#welcome-root</code> 就挂载；离开时先淡出，新内容换上后再卸载。</li>
<li><code>#welcome-root</code> 挂载时会被移到 <code>&lt;body&gt;</code> 下面。因为 <code>position: fixed</code> 的元素，如果某个祖先设置了 <code>transform</code>，就会相对那个祖先定位，而不是相对窗口；主题切换页面时会给内容区域加 <code>transform</code> 做动画。</li>
<li>样式全部限定在 <code>#welcome-root</code> 里。原来针对 <code>html, body</code> 的”禁止滚动”改成 <code>html:has(#welcome-root) { overflow: hidden }</code>：欢迎页在的时候生效，换走了自然失效，不用手动恢复。几个通用的元素 ID（<code>#main</code>、<code>#menu</code> 等）加了 <code>wl-</code> 前缀，避免和主题冲突。</li>
<li>被盖住的博客页面会在后台照常加载。看起来多加载了东西，其实相当于提前把博客的样式和壁纸缓存好了：访客在欢迎页看动画的时候它们就加载完了，点进博客反而更快。</li>
<li>又踩了一个时序坑：<code>controller.ts</code> 要等 swup 初始化好才能注册钩子，而主题的 swup 是在页面空闲时才初始化的，比这个脚本晚。光监听 <code>swup:enable</code> 事件有时会错过，最后加了定时检查兜底。</li>
</ul><p>欢迎页上的 GitHub 按钮是外部链接，在当前页面打开会把整个页面换掉，音乐也就停了，所以改成在新标签页打开（<code>target="_blank"</code>）。博客导航栏和横幅上的 GitHub 链接，主题本来就是在新标签页打开的。</p><p>现在只有按 F5 刷新页面才会整页重新加载，<code>music-resume.ts</code> 留着处理这种情况，会停一两秒再接着放。浏览器的自动播放策略也要考虑：大多数情况下 Chrome 允许接着放，Safari 可能拦下来，这时点一下播放，会从原来的位置继续。</p></section><section><h3>进入欢迎页时博客一闪而过<a href="#进入欢迎页时博客一闪而过"><span>#</span></a></h3><p>欢迎页并进博客的页面框架以后，出现了一个新问题：刷新欢迎页，或者从博客点按钮进欢迎页，都会先看到博客页面一闪，然后才是黑色的欢迎页。原因是欢迎页的黑色图层要等脚本跑完才能铺满窗口，而且铺上以后还从透明开始淡入，这段时间里底下的博客就露出来了。具体分三处：</p><ol>
<li><strong>刷新时，脚本执行前盖不住。</strong> 欢迎页在 HTML 里位于主题的 <code>#content-wrapper</code> 里面，这个元素带主题的入场动画：一开始透明度为 0，还有 <code>transform</code> 位移。里面的欢迎页跟着透明，而且前面说过，祖先有 <code>transform</code> 时 <code>position: fixed</code> 相对祖先定位，只能盖住中间的内容栏。把欢迎页移到 <code>&lt;body&gt;</code> 下的 <code>controller.ts</code> 是模块脚本，要等 HTML 解析完才执行，在这之前导航栏、横幅壁纸已经画出来了。</li>
<li><strong>从博客切换过来时，下载期间什么都没盖。</strong> swup 先把旧内容淡出、去下载欢迎页，导航栏和横幅一直在；内容换上后，因为欢迎页不算首页，主题还会把横幅从首页的高度收成普通页面的高度。</li>
<li><strong>欢迎页自己从透明淡入。</strong> <code>#welcome-root</code> 上有 0.3 秒的淡入，淡入时透出底下正在变化的博客。</li>
</ol><p>对应的改法，都在自己的文件里，没碰主题代码：</p><ul>
<li><code>#welcome-root</code> 的第一个子元素是一小段内联脚本（<code>&lt;script is:inline&gt;</code>），浏览器一解析到这里，就用 <code>document.currentScript.parentElement</code> 找到欢迎页、移到 <code>&lt;body&gt;</code> 下，不等模块脚本。放在最前面而不是最后面，是因为欢迎页里有 300KB 的内联 SVG，放最后要等它们都解析完。元素移走后，浏览器后面解析出的内容照样会放进这个元素里。同时加了一条 CSS：<code>#content-wrapper:has(#welcome-root)</code> 关掉入场动画和 <code>transform</code>，双保险。这条规则只在欢迎页还在内容区里的那一瞬间生效；一开始写成了 <code>html:has(#welcome-root) #content-wrapper</code>，范围太大，欢迎页移走后也一直生效。</li>
<li>黑色背景一开始就不透明，淡入只作用在里面的内容（<code>#wl-main</code>）上。</li>
<li><code>controller.ts</code> 在 swup 的 <code>visit:start</code> 里判断：如果要去欢迎页，立刻盖上一层黑色遮罩，挡住下载等待和横幅收缩；欢迎页铺满窗口后撤掉，<code>visit:end</code> 时再检查一次，保证切换失败时遮罩不会一直留着。遮罩的样式直接写在元素上，因为在博客页面上还没加载欢迎页的样式表。</li>
</ul></section><section><h3>花瓶的线条不”长”了<a href="#花瓶的线条不长了"><span>#</span></a></h3><p>上线后对比旧版，发现左边的花瓶少了一个效果：旧版是线条沿着轮廓慢慢”长”出来，新版只是整体淡入，看着发虚。</p><p>查了一下，是移植时漏掉了一行。旧代码里花瓶有两组动画：<code>#gflower</code> 的透明度和颜色变化，以及 <code>drawToTimeline("#flower", tl, { duration: 38, strokeWidth: 1, position: 5 })</code>，后者从第 5 秒开始、用 38 秒把描边一点点画出来。新版只搬了前一组。</p><p>描边”生长”的原理是 SVG 的虚线属性：<code>stroke-dasharray: 已画长度 剩余长度</code>。用 <code>getTotalLength()</code> 拿到路径总长 L，从 <code>0 L</code>（实线段长 0，全是空白）补间到 <code>L 0</code>（整条都是实线），线条就像沿着路径画出来一样。花瓶整幅图其实是一条 260KB 的 <code>&lt;path&gt;</code>，所以是从一处开始，沿着轮廓一路画下去。</p><p>补回这一行时还顺手修了补间引擎的一个问题：描边动画原来用的是 <code>to</code>，开始之前读到的起始值是”已画完”，于是开始前会先显示完整的描边，到点才清空再画；每轮循环重新开始时也一样。改成 <code>fromTo</code> 并明确起点 <code>draw: 0</code> 后，开始之前一直保持”还没画”的状态。右边圆形图案的描边也跟着受益。</p></section><section><h3>评论：从 Gitment 到 Giscus<a href="#评论从-gitment-到-giscus"><span>#</span></a></h3><p>旧博客用的是 Gitment：每篇文章对应仓库里的一个 GitHub Issue，访客用 GitHub 账号登录后评论，评论就是 Issue 下面的回复。翻了一下，当年每篇文章都建了 Issue，真正有评论的只有两篇，各一条，这次就不迁了。Gitment 本身早就不能用了，它依赖作者搭的一个登录中转服务，那个服务已经停了。</p><p>Firefly 内置了五种评论系统，改 <code>src/config/commentConfig.ts</code> 就能切换：</p>

<table><thead><tr><th>方案</th><th>评论存在哪</th><th>访客要登录吗</th><th>要自己搭服务吗</th><th>国内访问</th></tr></thead><tbody><tr><td>Giscus</td><td>仓库的 GitHub Discussions</td><td>要，GitHub 账号</td><td>不用</td><td>能用，偶尔慢</td></tr><tr><td>Twikoo</td><td>自己部署的云函数 + 数据库</td><td>不用</td><td>要</td><td>好</td></tr><tr><td>Waline</td><td>自己部署的后端 + 数据库</td><td>不用</td><td>要</td><td>看部署在哪</td></tr><tr><td>Artalk</td><td>自己的服务器</td><td>不用</td><td>要</td><td>好</td></tr><tr><td>Disqus</td><td>Disqus 公司</td><td>要</td><td>不用</td><td>基本打不开</td></tr></tbody></table><p>选了 <strong>Giscus</strong>。它和 Gitment 是一个思路，借 GitHub 存评论，只是从 Issues 换成了 Discussions。不用部署后端，不花钱，数据在自己仓库里，不怕第三方服务关停；技术博客的读者大多也有 GitHub 账号。代价是没有 GitHub 账号的人没法评论，想让任何人都能匿名评论，就得换 Twikoo 这类要自己搭后端的方案。</p><p>配置步骤：</p><ol>
<li>仓库 Settings → General → Features 里打开 <strong>Discussions</strong>。Giscus 要求仓库是公开的，前面为了 GitHub Pages 已经改成公开了。</li>
<li>安装 Giscus 的 GitHub App，只授权这一个仓库。</li>
<li>在 <code>commentConfig.ts</code> 里把 <code>type</code> 改成 <code>giscus</code>，填上仓库名、仓库 ID、分类和分类 ID。分类用的是 <strong>Announcements</strong>，这是 Giscus 推荐的：这个分类只有仓库管理员和 Giscus 能新建讨论，别人没法在里面乱发帖。</li>
<li><code>mapping</code> 用 <code>pathname</code>：按文章网址找对应的讨论。主站和备用站的同一篇文章网址路径一样，所以两边共用一组评论；网址是 <code>slug</code> 决定的、不会变，评论也就不会对不上。</li>
</ol><p>仓库 ID 和分类 ID 一般是去 giscus.app 填仓库名自动生成的。这次是直接从 GitHub 的接口查到的：仓库信息里的 <code>node_id</code> 就是仓库 ID，分类 ID 从开启 Discussions 时自动生成的那条欢迎帖里能看到。</p><p>顺带处理了一个安全问题：旧的 Hexo 配置文件里写着 Gitment 用的 GitHub OAuth 应用密钥（<code>client_secret</code>），它在 <code>hexoWorkspace</code> 仓库里，也被打包进了 <code>hexo-site</code> 分支的每个页面。仓库公开后谁都能看到，所以把那个 OAuth 应用删掉了。教训是：<strong>往仓库里提交配置前，先确认里面没有密钥</strong>。前端页面里出现的东西，就当作是公开的。</p></section><section><h3>其他小事<a href="#其他小事"><span>#</span></a></h3><ul>
<li><strong>横幅壁纸</strong>换成了饥荒联机版的加载界面，开了轮播，淡入淡出切换。头像换回了原来 Hexo 博客用的柴犬。</li>
<li><strong>壁纸换成了无损原图</strong>：两张壁纸是游戏里的加载界面 <em>Open the Gateway</em> 和 <em>Axiom Visus</em>。本来想换 4K 的，查了一圈发现没有：官方动画短片在 YouTube 上最高只有 1080p；游戏文件里的加载界面原图也就 1920×1080 和 1820×1024，饥荒 Wiki 上收录的一百多张加载界面里，最大就是 1920×1080，网上标着 4K 的都是放大的。能改进的只有画质：原来用的是有损压缩过的 WebP，主题构建时还会再压一次；换成 Wiki 上从游戏里提取的无损原图（转成无损 WebP 存进仓库，像素完全一致，文件比 PNG 小），构建时只压一次。主题在电脑上最大只输出 1920 宽的壁纸，所以原图有 1920 宽就够了，再大也用不上。
<ul>
<li>一个小插曲：Wiki 会根据浏览器支持的格式返回图片，网址是 <code>.png</code>，用浏览器”存储图像为”存下来的却可能是 WebP。文件头是 <code>RIFF....WEBPVP8L</code>，<code>VP8L</code> 表示无损 WebP；如果是 <code>VP8 </code>（后面带空格）就是有损的。</li>
</ul>
</li>
<li><strong>关掉了用不上的功能</strong>：友链、留言板、动态、相册、公告、打赏、看板娘。在 <code>src/config/siteConfig.ts</code> 里都是一个开关。</li>
<li><strong>删掉了主题仓库自带的 GitHub 配置</strong>：Issue 模板、赞助按钮、Dependabot。Dependabot 是给主题作者自己的仓库自动升级依赖用的，留着它会在我的仓库里不停建分支。</li>
<li><strong><code>pnpm new-post</code> 也改了</strong>：生成的 <code>slug</code> 只取文件名部分（不带文件夹），日期带上 <code>+08:00</code> 时区。</li>
<li><strong>文章日期加了文字</strong>：主题在文章页标题下会同时显示发布日期和更新日期，但只用两个图标区分，并排放着分不清。改了 <code>PostMeta.astro</code>，在日期前加上「发布于」「更新于」（主题自带这两条文案，直接复用）。首页列表卡片本来就不显示更新日期，保持不变。</li>
<li><strong>点 PDF 链接回不去</strong>：文章里的专利 PDF 链接，点了以后地址栏变成 <code>.pdf</code>，页面却没换，浏览器返回也乱了。原因是主题用 swup 做无刷新页面切换，它默认拦截所有站内链接，把 PDF 也当成网页去加载。在 <code>astro.config.mjs</code> 的 swup 配置里加了 <code>ignore</code>，PDF、压缩包、Office 文档、音视频这类文件交给浏览器正常打开。</li>
<li><strong>导航栏图标闪一下</strong>：导航栏的 logo 直接用 <code>favicon.svg</code>，这个 SVG 只写了 viewBox，没写宽高；<code>&lt;img&gt;</code> 的尺寸又全靠 CSS 类。整页刷新时，CSS 加载完之前浏览器会按默认尺寸把它画出来，就会看到一个大图标一闪而过。给 <code>&lt;img&gt;</code> 直接写上 <code>width="28" height="28"</code> 就好了。</li>
<li><strong>博客名改回朴素的写法</strong>：原来叫 <code>À Çraftman</code>，A 和 C 头上加了法语的变音符号，当时觉得有意思，后来看着有点非主流：不好打、不好搜，“Craftman” 本身也拼错了。改成了 <strong>A Craftsman</strong>（工匠），和描述”想当木匠的程序员！“对得上。站点标题、横幅标题、欢迎页描述、图标注释一起改了。</li>
<li><strong>页脚补上备案号</strong>：tracerzzz.cn 在阿里云做过 ICP 备案，按规定网站底部要显示备案号并链接到工信部的备案系统。旧 Hexo 博客的页脚有这一行，迁移时漏掉了。主题留了 <code>src/config/FooterConfig.html</code> 专门放自定义页脚，加一行 <code>京ICP备18058118号-1</code> 就行。用的是网站备案号（主体备案号后面带 <code>-1</code>，旧博客只写了主体号）。旧博客链接的 <code>www.miitbeian.gov.cn</code> 早已停用，现在的地址是 <code>https://beian.miit.gov.cn/</code>。链接只能指向首页，不能带上备案号直接跳到查询结果：备案系统是单页应用，查询要先过滑块验证，结果页没有可分享的网址。</li>
<li><strong>本地预览的小坑</strong>：切到新主题的分支后，再运行一次 <code>pnpm dev</code>，如果旧主题的预览服务还开着，新的会自动换到 4322 端口。这时去 4321 端口看到的还是旧主题。</li>
</ul></section><section><h3>网站图标：丁字尺<a href="#网站图标丁字尺"><span>#</span></a></h3><p>原来的网站图标（favicon）是 Hexo 时代留下的，导航栏左上角是主题图标库里的锤子扳手，两者也不统一。这次一起换成了自己设计的”丁字尺”：</p><ul>
<li><strong>为什么是丁字尺</strong>：tracerZzz 的 T，本身就是一件工具。丁字尺是画直线用的 T 形尺子，木工、制图都用，竖杆上有刻度，正好对应博客名 A Craftsman（工匠）。小尺寸下刻度看不见，就是一个干净的 T。</li>
<li><strong>试过的其他方向</strong>：榫卯（燕尾榫、银锭榫、透榫，平面和立体都画过）、把 tracerZzz 的 Z 做进榫卯里、一笔画的 T。榫卯最有手艺感，但要让人一眼看出是榫卯，就得画成立体、加细节，到 16px 的标签页图标里全糊了；反而最简单的丁字尺在每个尺寸都清楚。图标设计最后比的是<strong>小尺寸下还认不认得出来</strong>。</li>
<li><strong>一套图标三个文件</strong>，都在 <code>public/</code> 下：
<ul>
<li><code>favicon.svg</code>：矢量图，任意大小都清晰，现代浏览器优先用它；导航栏的 logo 也直接用这个文件。</li>
<li><code>favicon.ico</code>：里面打包了 16、32、48 三种尺寸的位图，给不支持 SVG 图标的老浏览器。</li>
<li><code>apple-touch-icon.png</code>：180×180，苹果设备”添加到主屏幕”时用。这张<strong>不带圆角</strong>，因为 iOS 会自己按系统的形状裁圆角，自带圆角反而会在四个角留下底色。</li>
</ul>
</li>
<li><strong>怎么生成的</strong>：先手写 SVG（一个圆角方块加两个矩形，刻度是四条短线），再用无头浏览器把 SVG 渲染成各尺寸的 PNG，最后用 Python 的 Pillow 把 16/32/48 三张合成一个 <code>.ico</code>。</li>
<li><strong>配置</strong>：在 <code>src/config/siteConfig.ts</code> 的 <code>favicon</code> 里列出这三个文件；<code>navbar.logo</code> 从图标库的 <code>icon</code> 类型改成 <code>image</code> 类型，指向 <code>/favicon.svg</code>。都是主题提供的配置项，不用改源码。</li>
</ul></section><section><h3>加粗紧挨中文标点时不生效<a href="#加粗紧挨中文标点时不生效"><span>#</span></a></h3><p>写文章时发现，有些加粗在页面上原样显示成了星号，比如 <code>**OCLP 版本：2.5.1。**下载时……</code>、<code>是**「需要时启用」**开着</code>。</p><p>这不是主题的问题，而是 Markdown 规范本身的规定。CommonMark 判断一对 <code>**</code> 能不能算加粗，要看它两边的字符：开头的 <code>**</code> 如果后面紧跟标点，那它前面必须是空格或标点；结尾的 <code>**</code> 如果前面紧挨标点，那它后面也必须是空格或标点。这套规则是照着英文设计的，英文单词之间有空格，几乎不会撞上；中文不用空格，<code>。**下</code> 这种「标点 + 星号 + 汉字」的组合就被判成了普通文字。</p><p>解决办法是装 <a href="https://github.com/tats-u/markdown-cjk-friendly" target="_blank">remark-cjk-friendly</a> 插件，它专门修正中日韩文字的这条规则，放在 <code>astro.config.mjs</code> 的 <code>remarkPlugins</code> 第一个：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>pnpm</span><span> </span><span>add</span><span> </span><span>remark-cjk-friendly</span></div></div></code></pre><div><div></div><div></div></div></figure></div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>import</span><span> </span><span>remarkCjkFriendly</span><span> </span><span>from</span><span> </span><span>"remark-cjk-friendly"</span><span>;</span></div></div><div><div><div>2</div></div><div><span>// ...</span></div></div><div><div><div>3</div></div><div><span><span>remarkPlugins</span><span>: [</span><span>remarkCjkFriendly</span><span>, </span></span><span>/* 其他插件 */</span><span>],</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>装之前也可以绕过：在星号外侧加个空格，或者把标点挪到加粗外面。但一来容易忘，二来 Typora、Obsidian 预览时是正常的，只有发布后才发现，所以还是在构建这一层统一解决。</p></section><section><h3>把对主题的修改记成清单<a href="#把对主题的修改记成清单"><span>#</span></a></h3><p>上面这些修复，有几处是主题没有提供配置、只能改源码的。这个仓库的主题代码是复制进来的，不是从主题仓库 fork 的，以后升级主题要手动合并，改过的文件可能和新版本冲突。</p><p>所以在仓库根目录加了一份 <code>CUSTOMIZATIONS.md</code>，记录和主题原版不一样的每一处：</p>

<table><thead><tr><th>类型</th><th>内容</th></tr></thead><tbody><tr><td>主题源码（升级时最容易冲突）</td><td><code>astro.config.mjs</code>（注入自己的两个脚本：刷新后接着放音乐、欢迎页的挂载和卸载）、<code>layout-utils.ts</code> / <code>navigation-utils.ts</code> / <code>swup-transitions.ts</code>（分页算首页）、<code>SharePoster.svelte</code> / <code>License.astro</code>（分享用当前域名）、<code>new-post.js</code>（slug 和时区）</td></tr><tr><td>配置文件</td><td><code>src/config/</code> 下的站点信息、导航、头像、壁纸、侧边栏、音乐播放器（换成自己的网易云歌单）</td></tr><tr><td>自己加的文件</td><td>欢迎页（套在主题的 <code>MainGridLayout</code> 里）、<code>music-resume.ts</code>、旧链接跳转页、ESA 配置、检查脚本（升级不会冲突，但欢迎页依赖主题的页面框架和 <code>#swup-container</code>，升级后要看一眼）</td></tr><tr><td>删掉的文件</td><td>主题仓库自己的 CI、Issue 模板、Dependabot、Vercel / Cloudflare 配置</td></tr></tbody></table><p>每一项都写了改了什么、为什么改、升级后怎么检查，最后附上升级步骤，以及当前所基于的主题版本（Firefly v6.16.8）。规则是：以后每改一次主题源码，都要同步更新这份清单。</p><p>网址规则这类问题，自动检查能兜底：网址变了构建就会失败。但残影、横幅、分享链接这些是显示上的问题，检查脚本发现不了，只能靠清单里的”升级后怎么检查”逐项人工看一遍。</p></section><section><h3>主题和 Astro 要不要经常升级<a href="#主题和-astro-要不要经常升级"><span>#</span></a></h3><p>Astro 更新很勤：大版本大约一年一次（会有不兼容的改动），小版本大约一个月一次，补丁几乎每周都有。但个人博客不需要追：</p><ul>
<li>线上只是构建好的静态文件，没有服务器程序，Astro 的漏洞一般影响不到已经发布的网站；</li>
<li>Astro 只在构建时运行，跑完就结束，不对外提供服务；</li>
<li>Firefly 的 <code>package.json</code> 里把 Astro 锁在一个精确版本，主题作者是基于这个版本测试的，单独升级 Astro 反而可能让主题出问题。</li>
</ul><p>所以策略是<strong>不单独升级 Astro，只跟着主题升级</strong>，几个月看一次 Firefly 有没有新版本，有需要的功能再升；遇到严重的安全公告再单独处理。删掉 Dependabot 也是这个考虑：它会每周自动提交依赖升级，对博客来说是干扰。</p><p>另外还定了一条规矩：<strong>以后只要改了博客的设定，都同步更新这篇文章</strong>，和 <code>CUSTOMIZATIONS.md</code> 一起，作为这个博客的”施工记录”。</p></section></section>
<section><h2>十三、写作工具配置<a href="#十三写作工具配置"><span>#</span></a></h2><p>图片跟着文章走的前提是：<strong>编辑器粘贴图片时，要自动存到 <code>文章名.assets/</code> 文件夹，并写成相对路径</strong>。否则图片会落到别的地方，或者变成本机绝对路径，换台电脑就打不开了。</p><section><h3>Typora<a href="#typora"><span>#</span></a></h3><p>偏好设置（<code>Cmd + ,</code>）→ <strong>图像</strong>：</p><ul>
<li><strong>插入图片时</strong>：下拉框里直接选 Typora 自带的 **复制图片到 ./<span><span>filename.assets文件夹∗∗。‘{filename}.assets 文件夹**。`</span><span><span><span></span><span><span>f</span><span>i</span><span>l</span><span>e</span><span>nam</span><span>e</span></span><span>.</span><span>a</span><span>sse</span><span>t</span><span>s</span><span>文件夹</span><span></span><span>∗</span><span></span></span><span><span></span><span>∗</span><span>。</span><span>‘</span></span></span></span>{filename}<code>是 Typora 的变量，代表当前文件名（不带</code>.md`）。</li>
<li>勾选 <strong>对本地位置的图片应用上述规则</strong>：从电脑里拖进来的图片也会复制过去，原文件留在原地。</li>
<li>勾选 <strong>对网络位置的图片应用上述规则</strong>：粘贴网上的图片时，会下载到本地，不留外链。以后不会再出现图床失效的问题。</li>
<li>勾选 <strong>优先使用相对路径</strong>。</li>
<li>勾选 <strong>为相对路径添加 <code>./</code></strong>：和现有文章的写法 <code>./xxx.assets/xxx.png</code> 保持一致。</li>
<li><strong>插入时自动转义图片 URL</strong>：不勾。勾了会把中文文件名转成 <code>%E4%BB%8E...</code> 这样的编码，不方便阅读，Astro 不需要它。</li>
<li><strong>允许根据 YAML 设置自动上传图片</strong>：不勾。</li>
<li><strong>上传服务</strong>：选”无”。不再用 PicGo 上传图床。</li>
</ul><p>配置好后，粘贴一张截图，Typora 会生成 <code>![](./文章名.assets/image-20261023103000000.png)</code>，文件夹不存在会自动创建。</p><blockquote><p>注意：先保存文件再粘贴图片。新建的文件还没有文件名，Typora 不知道该把图片放到哪里。</p></blockquote></section><section><h3>Obsidian<a href="#obsidian"><span>#</span></a></h3><p><strong>库（vault）只打开 <code>src/content/posts</code></strong>，不要打开整个项目，否则它会去索引 <code>node_modules</code> 里几万个文件。Obsidian 会在这里生成 <code>.obsidian/</code> 配置文件夹，已经写进 <code>.gitignore</code>，不会被提交。</p><p>设置 → <strong>文件与链接</strong>：</p><ul>
<li><strong>内部链接类型</strong>：选”基于当前笔记的相对路径”。</li>
<li><strong>使用 <a href="/posts/Wiki%20%E9%93%BE%E6%8E%A5/">Wiki 链接</a></strong>：关闭。Astro 不认识 <code>[[ ]]</code> 和 <code>![[图片]]</code>，必须是标准 Markdown 链接。</li>
<li><strong>自动更新内部链接</strong>：打开。在 Obsidian 里改文件名、移动文件时，引用它的链接会跟着改。</li>
</ul><p><strong>图片位置</strong>：Obsidian 自带的”附件默认存放路径”只能选一个固定名字的子文件夹（比如统一叫 <code>attachments</code>），做不到”每篇文章一个同名 <code>.assets</code>”。需要装社区插件 <strong>Custom Attachment Location</strong>：</p><ul>
<li><strong>附件位置</strong>：<code>./${noteFileName}.assets</code></li>
<li><strong>附件文件名</strong>：<code>${date:YYYYMMDDHHmmssSSS}</code>，用时间戳命名。Obsidian 默认的 <code>Pasted image 2026...png</code> 带空格，放在 Markdown 链接里容易出问题。</li>
<li>插件的变量写法可能随版本变化，以插件设置页里的说明为准。</li>
</ul><p><strong>文章模板</strong>：用 Obsidian 自带的”模板”核心插件，存一份开头的元信息，新建文章时插入。注意 <code>published</code> 要写成带时区的格式，比如 <code>2026-10-23T10:30:00+08:00</code>。Obsidian 属性面板里的日期默认不带时区，会被当成 UTC，显示的时间差 8 小时。</p><p>两个编辑器的约定一致，同一篇文章用 Typora 写还是用 Obsidian 写都可以，图片位置和链接写法都一样。</p></section></section>
<section><h2>十四、现在的发布流程<a href="#十四现在的发布流程"><span>#</span></a></h2><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>本地写文章 → git push 到 master</span></div></div><div><div><div>2</div></div><div><span><span>                 </span></span><span>│</span></div></div><div><div><div>3</div></div><div><span><span>                 </span></span><span>├──► GitHub Actions：构建 → 检查 → 发布到 tracerzzz.github.io</span></div></div><div><div><div>4</div></div><div><span><span>                 </span></span><span>│</span></div></div><div><div><div>5</div></div><div><span><span>                 </span></span><span>└──► ESA Pages：拉取 → 构建 → 发布到 tracerzzz.cn</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>日常写作就是：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>pnpm</span><span> </span><span>new-post</span><span> </span><span>git/新文章标题</span><span>   </span><span># 生成带元信息的新文件</span></div></div><div><div><div>2</div></div><div><span>pnpm</span><span> </span><span>dev</span><span>                       </span><span># 边写边预览</span></div></div><div><div><div>3</div></div><div><span>git</span><span> </span><span>add</span><span> </span><span>-A</span><span> &amp;&amp; </span><span>git</span><span> </span><span>commit</span><span> </span><span>-m</span><span> </span><span>"新文章"</span><span> &amp;&amp; </span><span>git</span><span> </span><span>push</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>两三分钟后，两个站点都更新了。构建失败不会替换线上版本；ESA 的部署记录里也可以一键回滚到之前的版本。</p><section><h3>换一台电脑写博客<a href="#换一台电脑写博客"><span>#</span></a></h3><p>部署全在云端，本地只负责写、预览和推送，所以换一台电脑只要装这几样：</p>

<table><thead><tr><th>工具</th><th>要求</th><th>用途</th></tr></thead><tbody><tr><td>Git</td><td>任意新版本，macOS 自带</td><td>拉代码、提交、推送</td></tr><tr><td>Node.js</td><td>22.23 以上（<code>package.json</code> 里的 <code>engines</code>）</td><td>跑 Astro</td></tr><tr><td>pnpm</td><td>11.22.0（<code>package.json</code> 里的 <code>packageManager</code>）</td><td>装依赖、预览、构建</td></tr><tr><td>GitHub 登录</td><td>个人访问令牌，或 <code>gh auth login</code></td><td>推送</td></tr></tbody></table><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>corepack</span><span> </span><span>enable</span><span>                                          </span><span># Node 自带，按 packageManager 自动用对的 pnpm 版本</span></div></div><div><div><div>2</div></div><div><span>npm</span><span> </span><span>config</span><span> </span><span>set</span><span> </span><span>registry</span><span> </span><span>https://registry.npmmirror.com</span><span>   </span><span># 国内 npm 镜像</span></div></div><div><div><div>3</div></div><div><span>mkdir</span><span> </span><span>-p</span><span> </span><span>~/blog</span><span> &amp;&amp; </span><span>cd</span><span> </span><span>~/blog</span></div></div><div><div><div>4</div></div><div><span>git</span><span> </span><span>clone</span><span> </span><span>https://github.com/tracerZzz/tracerZzz.github.io.git</span><span> </span><span>astro</span></div></div><div><div><div>5</div></div><div><span>cd</span><span> </span><span>astro</span><span> &amp;&amp; </span><span>pnpm</span><span> </span><span>install</span><span> &amp;&amp; </span><span>pnpm</span><span> </span><span>dev</span></div></div><div><div><div>6</div></div><div><span>git</span><span> </span><span>config</span><span> </span><span>--global</span><span> </span><span>user.name</span><span> </span><span>"tracerZzz"</span></div></div><div><div><div>7</div></div><div><span>git</span><span> </span><span>config</span><span> </span><span>--global</span><span> </span><span>user.email</span><span> </span><span>"tracerZzz@users.noreply.github.com"</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>用令牌推送时，第一次 <code>git push</code> 输入用户名和令牌（不是登录密码），macOS 自带的 git 会把它存进钥匙串，以后不用再输。</p><p>在一台<a href="/posts/oclp-sonoma-2016-macbook-pro/">用 OCLP 升到 Sonoma 的 2016 款 MacBook Pro</a> 上搭这套环境时，遇到两个坑：</p><ul>
<li><strong>Homebrew 装 Node 要现场编译几个小时</strong>：<code>brew install node@22</code> 之后开始一个个编译 openssl、python、cmake。Homebrew 已经不再为 Intel 芯片 + Sonoma 这种组合提供编译好的安装包（bottle），只能从源码编。苹果芯片的 Mac 不会这样。解决：Node 不走 Homebrew，用官方 <code>.pkg</code> 安装包（国内可以从 npmmirror 镜像的 <code>latest-v22.x</code> 目录下载）或者 nvm，几分钟就好。</li>
<li><strong>Homebrew 是另一个用户装的</strong>：Intel Mac 上 Homebrew 装在 <code>/usr/local</code>，所有者是当初安装它的用户，换了用户就没有写入权限。用 <code>sudo chown -R $(whoami):admin $(brew --prefix)/*</code> 改一下所有权。</li>
</ul></section></section>
<section><h2>十五、还没做的<a href="#十五还没做的"><span>#</span></a></h2><ul>
<li>正式切换到 Obsidian 管理所有 Markdown。</li>
<li>草稿慢慢整理发布。</li>
</ul><p>原本还列了两项，后来决定不做：<code>www</code> 跳转到根域名（<code>www</code> 已经在 ESA 上绑定，能直接访问，canonical 指向根域名，不需要再跳）；访问统计（ESA 控制台自带基本的访问数据，够用了，不再额外接第三方统计脚本）。</p></section>
<section><h2>回头看<a href="#回头看"><span>#</span></a></h2><p>这次迁移最大的收获，是把”文章依赖外部服务”变成了”所有东西都在一个仓库里”：文章、图片、欢迎页、构建配置、部署配置，全部跟着 git 走。图床过期、平台变动、换主题、换编辑器，都不会再让博客坏掉。后来真的换了一次主题，从开分支试用到正式上线只用了一个下午，所有网址一个没变，算是验证了这个结构。</p><p>另一个体会是：<strong>大部分问题都出在”两个东西之间的约定”上</strong>。比如空间名和 bucket 名不是一回事，BSD sed 和 GNU sed 行为不同，bash 3.2 不能按字节处理 UTF-8，Jekyll 会去解析 <code>.astro</code> 文件，泛域名证书不包含根域名。每个问题单独看都不难，难的是知道该往哪个方向怀疑。</p></section>]]></content>
    </entry>
    <entry>
      <id>https://tracerzzz.cn/posts/spark-cdn-topn-min-heap/</id>
      <title type="text">每小时 10T 日志里找热点：用 Spark 和小根堆计算 CDN 的 TopN</title>
      <published>2020-06-15T12:00:00.000Z</published>
      <updated>2020-06-15T12:00:00.000Z</updated>
      <author><name>tracerZzz</name></author>
      <link rel="alternate" href="https://tracerzzz.cn/posts/spark-cdn-topn-min-heap/"/>
      <summary type="text">在百度云 CDN 时，我负责用 Spark 从访问日志里统计热点：每个域名、每个用户、全局的 Top URL、Top Referer 等，按请求数和流量排序，小时级日志最多 10T。这篇讲为什么不能直接全量排序，小根堆怎么把内存控制在 N 个元素，MapReduce 阶段怎么合并，以及多层级统计时怎么复用下层结果。</summary>
      <content type="html"><![CDATA[<p>在百度云 CDN 时，我做过一个热点分析的任务：从 CDN 节点的 nginx 访问日志里，统计出各个维度的热门资源，比如每个域名访问最多的 URL、带来流量最多的 URL、主要的 Referer 和 User-Agent。这类热点数据通常用于控制台展示、排查热点、缓存预热和发现盗链。</p>
<p>日志量很大，小时级最高能到 10T。这篇讲这个任务的核心思路：<strong>用小根堆在 MapReduce 里算 TopN</strong>。</p>
<blockquote><p>说明：这篇是 2026 年根据当年在百度做的项目回忆整理的，发布时间标在项目所在的时间段（2019–2021 年）。时间久远，当时的具体代码和参数已经记不清，下面的代码是按通用做法整理的 PySpark 示例，用来说明思路，不是原始实现。</p></blockquote>
<section><h2>一、要统计什么<a href="#一要统计什么"><span>#</span></a></h2><p>统计分三个层级，从小到大：</p><ul>
<li><strong>域名级</strong>：每个加速域名下的 TopN；</li>
<li><strong>用户级</strong>：一个客户名下所有域名合在一起的 TopN；</li>
<li><strong>全局</strong>：整个平台的 TopN。</li>
</ul><p>每个层级都统计几类对象，按不同的指标排序：</p>

<table><thead><tr><th>统计对象</th><th>排序指标</th></tr></thead><tbody><tr><td>URL</td><td>请求数（PV）、流量</td></tr><tr><td>Referer</td><td>请求数</td></tr><tr><td>User-Agent</td><td>请求数</td></tr></tbody></table><p>这和主流 CDN 厂商控制台提供的 TOP 统计基本一致。比如百度智能云 CDN 的文档里就列了 TOP URL、TOP Referer、TOP HTTP 状态码、TOP 域名，统计范围是「当前域名或当前用户」。</p></section>
<section><h2>二、为什么不能直接全量排序<a href="#二为什么不能直接全量排序"><span>#</span></a></h2><p>最直接的写法是：按 (域名, URL) 分组求和，然后按值排序取前 N 个。</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>totals </span><span>=</span><span> logs.</span><span>map</span><span>(</span></span><span>lambda</span><span> </span><span>r</span><span><span>: ((r.domain, r.url), r.bytes)).</span><span>reduceByKey</span><span>(add)</span></span></div></div><div><div><div>2</div></div><div><span><span>top </span><span>=</span><span> totals.</span><span>sortBy</span><span>(</span></span><span>lambda</span><span> </span><span>kv</span><span><span>: </span><span>-</span><span>kv[</span></span><span>1</span><span><span>]).</span><span>take</span><span>(</span></span><span>100</span><span>)</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>问题在于规模：</p><ul>
<li>一小时的日志里，不同的 URL 数量是亿级的，带参数的动态 URL 更多；</li>
<li><code>sortBy</code> 是<strong>全量排序</strong>，所有数据都要按值重新分区（shuffle），代价和数据量成正比；</li>
<li>我们只要每个域名的前 N 个，比如前 100 个，却把上亿条数据完整地排了一遍，绝大部分排序工作都是浪费的。</li>
</ul><p>而且这还只是全局的一个 TopN。要按域名分别取 TopN，就得 <code>groupByKey</code> 把每个域名的所有 URL 拉到一起再排序，大域名的数据会全部挤在一个 executor 上，很容易内存溢出。</p></section>
<section><h2>三、小根堆：只记住前 N 名<a href="#三小根堆只记住前-n-名"><span>#</span></a></h2><p>取 TopN 有一个经典的做法：<strong>维护一个大小为 N 的小根堆</strong>。</p><ul>
<li>小根堆的堆顶，永远是堆里<strong>最小</strong>的元素，也就是「当前第 N 名」；</li>
<li>来一个新元素，如果堆还没满，直接放进去；</li>
<li>如果堆满了，就和堆顶比：比堆顶大，说明它能挤进前 N 名，替换掉堆顶；比堆顶小，直接丢弃。</li>
</ul><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>import</span><span> heapq</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span><span>N </span><span>=</span><span> </span></span><span>100</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>def</span><span> </span><span>push</span><span>(</span><span>heap</span><span>,</span><span><span> </span><span>item</span></span><span>):</span></div></div><div><div><div>6</div></div><div><span>    </span><span>"""item = (指标值, URL)；heap 最多保留 N 个最大的元素"""</span></div></div><div><div><div>7</div></div><div><span>    </span><span>if</span><span> </span><span>len</span><span><span>(heap) </span><span>&lt;</span><span> N:</span></span></div></div><div><div><div>8</div></div><div><span><span>        </span></span><span>heapq.</span><span>heappush</span><span>(heap, item)</span></div></div><div><div><div>9</div></div><div><span>    </span><span>elif</span><span><span> item </span><span>&gt;</span><span> heap[</span></span><span>0</span><span>]:            </span><span># 比当前第 N 名大</span></div></div><div><div><div>10</div></div><div><span><span>        </span></span><span>heapq.</span><span>heapreplace</span><span>(heap, item)</span></div></div><div><div><div>11</div></div><div><span>    </span><span>return</span><span> heap</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>为什么用<strong>小根堆</strong>取<strong>最大的</strong> N 个，而不是大根堆？因为我们需要随时知道「门槛」是多少，也就是前 N 名里最小的那个。小根堆的堆顶正好就是这个门槛，比较一次就能决定新元素要不要进来。</p><p>它的好处：</p><ul>
<li><strong>内存固定</strong>：不管输入多少条，堆里永远只有 N 个元素；</li>
<li><strong>时间省</strong>：每个元素最多一次堆操作，复杂度 O(M log N)。M 是元素总数，N 是 100 这样的小数，log N 几乎是常数；而全量排序是 O(M log M)。</li>
</ul></section>
<section><h2>四、放进 MapReduce：先局部取 TopN，再合并<a href="#四放进-mapreduce先局部取-topn再合并"><span>#</span></a></h2><p>先看整体流程，后面两节逐步展开：</p><div><div><div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>按用户合并</p></span></div><div><span><p>全部合并</p></span></div><div><span><p>Referer、UA 跨域名，每层单独求和</p></span></div><div><span><p>nginx 访问日志<br /><br />小时级最高 10T</p></span></div><div><span><p>分区 1</p></span></div><div><span><p>分区 2</p></span></div><div><span><p>分区 …</p></span></div><div><span><p>① reduceByKey<br /><br />按（域名, URL）求和<br /><br />Map 端先局部求和再 shuffle</p></span></div><div><span><p>分区 1<br /><br />每个域名一个小根堆<br /><br />只留前 N</p></span></div><div><span><p>分区 2<br /><br />每个域名一个小根堆<br /><br />只留前 N</p></span></div><div><span><p>分区 …<br /><br />每个域名一个小根堆<br /><br />只留前 N</p></span></div><div><span><p>② aggregateByKey 合并<br /><br />同一域名的各分区堆合并<br /><br />仍只留 N 个</p></span></div><div><span><p>域名级 TopN<br /><br />（域名数 × N 条）</p></span></div><div><span><p>用户级 TopN</p></span></div><div><span><p>全局 TopN</p></span></div><div><span><p>各层级的<br /><br />Top Referer / Top UA</p></span></div>
</div><div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>按用户合并</p></span></div><div><span><p>全部合并</p></span></div><div><span><p>Referer、UA 跨域名，每层单独求和</p></span></div><div><span><p>nginx 访问日志<br /><br />小时级最高 10T</p></span></div><div><span><p>分区 1</p></span></div><div><span><p>分区 2</p></span></div><div><span><p>分区 …</p></span></div><div><span><p>① reduceByKey<br /><br />按（域名, URL）求和<br /><br />Map 端先局部求和再 shuffle</p></span></div><div><span><p>分区 1<br /><br />每个域名一个小根堆<br /><br />只留前 N</p></span></div><div><span><p>分区 2<br /><br />每个域名一个小根堆<br /><br />只留前 N</p></span></div><div><span><p>分区 …<br /><br />每个域名一个小根堆<br /><br />只留前 N</p></span></div><div><span><p>② aggregateByKey 合并<br /><br />同一域名的各分区堆合并<br /><br />仍只留 N 个</p></span></div><div><span><p>域名级 TopN<br /><br />（域名数 × N 条）</p></span></div><div><span><p>用户级 TopN</p></span></div><div><span><p>全局 TopN</p></span></div><div><span><p>各层级的<br /><br />Top Referer / Top UA</p></span></div>
</div></div></div><p>数据量在每一步都在收缩：原始日志先被求和压缩成「每个 URL 一条」，再被小根堆压缩成「每个域名 N 条」，用户级和全局只是在这个很小的结果上再合并一次。</p><p>小根堆在单机上很好用。到了分布式环境，关键是一个性质：</p><p><strong>全局的前 N 名，一定在各个分区各自的前 N 名里。</strong></p><p>道理很简单：如果某个元素在它所在的分区里连前 N 都排不进，说明那个分区里已经有 N 个比它大的元素了，它在全局也不可能进前 N。</p><p>所以计算可以分两步：</p><ol>
<li><strong>Map 端</strong>：每个分区各自维护一个小根堆，只保留本分区的前 N 名；</li>
<li><strong>Reduce 端</strong>：把各分区的堆合并，合并时还是用同一个 <code>push</code>，最后仍然只保留 N 个。</li>
</ol><p>每个分区交给下游的只有 N 条数据，shuffle 的数据量从「所有 URL」收缩到了「分区数 × N」。</p><p>在 Spark 里，「按域名分组、每组维护一个堆」可以用 <code>aggregateByKey</code> 实现，它天然就是这种「分区内先聚合、分区间再合并」的结构：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>from</span><span> operator </span><span>import</span><span> add</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>def</span><span> </span><span>merge</span><span>(</span><span>heap_a</span><span>,</span><span><span> </span><span>heap_b</span></span><span>):</span></div></div><div><div><div>4</div></div><div><span>    </span><span>for</span><span> item </span><span>in</span><span> heap_b:</span></div></div><div><div><div>5</div></div><div><span><span>        </span></span><span>push</span><span>(heap_a, item)</span></div></div><div><div><div>6</div></div><div><span>    </span><span>return</span><span> heap_a</span></div></div><div><div><div>7</div></div><div>
</div></div><div><div><div>8</div></div><div><span># 第一步：先把每个 (域名, URL) 在整个小时内的流量加总</span></div></div><div><div><div>9</div></div><div><span><span>totals </span><span>=</span><span> (logs</span></span></div></div><div><div><div>10</div></div><div><span><span>          </span></span><span>.</span><span>map</span><span>(</span><span>lambda</span><span> </span><span>r</span><span>: ((r.domain, r.url), r.bytes))</span></div></div><div><div><div>11</div></div><div><span><span>          </span></span><span>.</span><span>reduceByKey</span><span>(add))</span></div></div><div><div><div>12</div></div><div>
</div></div><div><div><div>13</div></div><div><span># 第二步：按域名维护大小为 N 的小根堆</span></div></div><div><div><div>14</div></div><div><span>#   seqOp：分区内，把 (流量, URL) 放进该域名的堆</span></div></div><div><div><div>15</div></div><div><span>#   combOp：分区间，两个堆合并</span></div></div><div><div><div>16</div></div><div><span><span>domain_top </span><span>=</span><span> (totals</span></span></div></div><div><div><div>17</div></div><div><span><span>              </span></span><span>.</span><span>map</span><span>(</span><span>lambda</span><span> </span><span>kv</span><span>: (kv[</span><span>0</span><span>][</span><span>0</span><span>], (kv[</span><span>1</span><span>], kv[</span><span>0</span><span>][</span><span>1</span><span>])))   </span><span># (域名, (流量, URL))</span></div></div><div><div><div>18</div></div><div><span><span>              </span></span><span>.</span><span>aggregateByKey</span><span>([], push, merge))</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>注意第一步不能省：同一个 URL 的日志分散在不同的分区里，必须先把它的总流量加起来，才能拿去比大小。第一步的 <code>reduceByKey</code> 也是先在 Map 端局部求和再 shuffle，数据量同样会大幅收缩。</p></section>
<section><h2>五、多层级：上一层复用下一层的结果<a href="#五多层级上一层复用下一层的结果"><span>#</span></a></h2><p>URL 有一个很好的性质：<strong>每个 URL 只属于一个域名，每个域名只属于一个用户。</strong></p><p>结合上一节的道理：</p><ul>
<li>一个用户的 Top N URL，一定在他名下各个域名的 Top N URL 里；</li>
<li>全局的 Top N URL，一定在所有域名的 Top N URL 里。</li>
</ul><p>所以用户级和全局的 TopN 不需要从原始数据重新算，<strong>直接把域名级的结果按用户合并一次就行</strong>：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span># domain_owner：域名 → 用户 的映射（广播出去）</span></div></div><div><div><div>2</div></div><div><span><span>user_top </span><span>=</span><span> (domain_top</span></span></div></div><div><div><div>3</div></div><div><span><span>            </span></span><span>.</span><span>map</span><span>(</span><span>lambda</span><span> </span><span>kv</span><span>: (domain_owner.value[kv[</span><span>0</span><span>]], kv[</span><span>1</span><span>]))</span></div></div><div><div><div>4</div></div><div><span><span>            </span></span><span>.</span><span>reduceByKey</span><span>(merge))</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span><span>global_top </span><span>=</span><span> domain_top.</span><span>values</span><span>().</span><span>reduce</span><span>(merge)</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>域名级的结果一共只有「域名数 × N」条，在这个基础上合并，开销几乎可以忽略。</p><p><strong>但 Referer 和 User-Agent 不能这么做</strong>：同一个 Referer 可能出现在很多个域名下，它在全局的总量，是在各个域名下的量加起来，而不在某个域名里排第几。一个 Referer 可能在每个域名下都排不进前 N，加起来却是全局第一。所以这类对象，在每个层级都要先按该层级的维度重新求和，再取 TopN。</p></section>
<section><h2>六、按流量和按请求数<a href="#六按流量和按请求数"><span>#</span></a></h2><p>URL 有两个排序指标：请求数和流量。这两个指标可以在一次遍历里同时计算：第一步求和时，同时累加请求数和流量，第二步分别维护两个堆。</p><p>为什么不单独做一个「按带宽排序」？因为在同一个统计时段里，带宽等于流量除以时间，两者的排序结果完全一样，没有必要重复计算。</p></section>
<section><h2>七、配置化：加指标不用重新发版<a href="#七配置化加指标不用重新发版"><span>#</span></a></h2><p>统计哪些对象、按什么指标、取多少个、在哪些层级统计，这些都写在一个 JSON 配置里，作为任务参数传入：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>{</span></div></div><div><div><div>2</div></div><div><span>  </span><span>"top_n"</span><span>: </span><span>100</span><span>,</span></div></div><div><div><div>3</div></div><div><span>  </span><span>"levels"</span><span>: [</span><span>"domain"</span><span>, </span><span>"user"</span><span>, </span><span>"global"</span><span>],</span></div></div><div><div><div>4</div></div><div><span>  </span><span>"metrics"</span><span>: [</span></div></div><div><div><div>5</div></div><div><span><span>    </span></span><span>{ </span><span>"name"</span><span>: </span><span>"top_url_pv"</span><span>,      </span><span>"key"</span><span>: </span><span>"url"</span><span>,        </span><span>"value"</span><span>: </span><span>"requests"</span><span> },</span></div></div><div><div><div>6</div></div><div><span><span>    </span></span><span>{ </span><span>"name"</span><span>: </span><span>"top_url_traffic"</span><span>, </span><span>"key"</span><span>: </span><span>"url"</span><span>,        </span><span>"value"</span><span>: </span><span>"bytes"</span><span> },</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>{ </span><span>"name"</span><span>: </span><span>"top_referer"</span><span>,     </span><span>"key"</span><span>: </span><span>"referer"</span><span>,    </span><span>"value"</span><span>: </span><span>"requests"</span><span> },</span></div></div><div><div><div>8</div></div><div><span><span>    </span></span><span>{ </span><span>"name"</span><span>: </span><span>"top_ua"</span><span>,          </span><span>"key"</span><span>: </span><span>"user_agent"</span><span>, </span><span>"value"</span><span>: </span><span>"requests"</span><span> }</span></div></div><div><div><div>9</div></div><div><span><span>  </span></span><span>]</span></div></div><div><div><div>10</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>程序根据配置动态生成计算逻辑。运营想加一个新的统计维度，比如 Top 客户端 IP，只要改配置，不用重新打包、部署 Spark 程序。</p></section>
<section><h2>八、几个实践中的问题<a href="#八几个实践中的问题"><span>#</span></a></h2><ul>
<li><strong>数据倾斜</strong>：头部大域名的日志量可能是普通域名的成千上万倍。好在 <code>reduceByKey</code> 和 <code>aggregateByKey</code> 都会先在 Map 端局部聚合，大域名的数据在进入 shuffle 之前就已经被压缩了，比 <code>groupByKey</code> 稳定得多。</li>
<li><strong>URL 归一化</strong>：同一个资源带上不同的查询参数（比如时间戳、签名），会被当成不同的 URL，把热点打散。统计前要按业务需要去掉无关参数，这一点和 CDN 缓存 key 的处理是一样的道理。</li>
<li><strong>N 要留余量</strong>：如果控制台展示前 100，计算时可以多保留一些。这样后续按其他条件过滤（比如去掉某类请求）后，依然能凑够 100 条。</li>
</ul></section>
<section><h2>九、总结<a href="#九总结"><span>#</span></a></h2><p>这个任务的核心就是一句话：<strong>只需要前 N 名的时候，就不要对全部数据排序。</strong></p><ul>
<li>单机上，用大小为 N 的小根堆，把内存固定在 N，把排序代价降到 O(M log N)；</li>
<li>分布式下，利用「全局前 N 一定在各分区前 N 里」，先局部取 TopN 再合并，让 shuffle 的数据量收缩几个数量级；</li>
<li>多层级时，对只属于一个上级的对象（URL），上层直接复用下层结果；对跨上级的对象（Referer、UA），每层单独求和。</li>
</ul></section>
<section><h2>参考<a href="#参考"><span>#</span></a></h2><ul>
<li><a href="https://intl.cloud.baidu.com/doc/CDN/s/Hjwvyeb4z-en" target="_blank">百度智能云 CDN：TOP 类数据</a></li>
<li><a href="https://spark.apache.org/docs/latest/api/python/reference/api/pyspark.RDD.aggregateByKey.html" target="_blank">PySpark aggregateByKey 文档</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://tracerzzz.cn/posts/rabbitmq/</id>
      <title type="text">RabbitMQ 发布订阅：CDN 配置下发和分布式账单任务的实践</title>
      <published>2018-12-31T16:00:00.000Z</published>
      <updated>2026-10-25T08:00:00.000Z</updated>
      <author><name>tracerZzz</name></author>
      <link rel="alternate" href="https://tracerzzz.cn/posts/rabbitmq/"/>
      <summary type="text">我在中国电信用 RabbitMQ 做过 CDN 配置下发系统，在百度用 RabbitMQ + Celery 做过分布式账单任务。这篇整理 RabbitMQ 的核心概念、交换机类型怎么选，以及消息不丢、不重、不乱序的几种做法。</summary>
      <content type="html"><![CDATA[<blockquote><p><strong>关于这篇文章</strong>：最初是 2019 年初在中国电信做 CDN 配置系统时留下的 RabbitMQ 安装笔记。2026 年 10 月重写：补充了后来在百度云 CDN 用 RabbitMQ + Celery 做分布式账单任务的经验，并整理了交换机选型和消息可靠性的做法。</p></blockquote>
<p>我用 RabbitMQ 做过两个系统：</p>
<ul>
<li><strong>中国电信 CDN 配置系统</strong>：中心生成 Nginx 302 调度、ATS 缓存、DNS 等组件的配置，通过 RabbitMQ 下发到各个节点，节点上的 agent 收到后更新配置文件、重启对应的 Docker 服务；缓存刷新任务也走同一条链路。</li>
<li><strong>百度云 CDN 定制化账单</strong>：每月给头部客户生成账单，任务通过 RabbitMQ 分发给多台机器并行计算，用 Celery 把「拉数据 → 计费 → 生成文件 → 上传存储 → 发邮件」编排成链式任务。</li>
</ul>
<p>这篇从这两个场景出发，整理 RabbitMQ 的用法和几个关键问题。</p>
<section><h2>一、核心概念<a href="#一核心概念"><span>#</span></a></h2><p>RabbitMQ 里生产者不直接把消息发给队列，而是发给<strong>交换机（exchange）</strong>，交换机按规则把消息路由到一个或多个<strong>队列（queue）</strong>，消费者再从队列里取。</p><div><div><div><div><span><p>routing key</p></span></div><div><span><p>binding</p></span></div><div><span><p>binding</p></span></div><div><span></span></div><div><span></span></div><div><span><p>生产者</p></span></div><div><span><p>交换机</p></span></div><div><span><p>队列 1</p></span></div><div><span><p>队列 2</p></span></div><div><span><p>消费者 1</p></span></div><div><span><p>消费者 2</p></span></div>
</div><div><div><span><p>routing key</p></span></div><div><span><p>binding</p></span></div><div><span><p>binding</p></span></div><div><span></span></div><div><span></span></div><div><span><p>生产者</p></span></div><div><span><p>交换机</p></span></div><div><span><p>队列 1</p></span></div><div><span><p>队列 2</p></span></div><div><span><p>消费者 1</p></span></div><div><span><p>消费者 2</p></span></div>
</div></div></div><p>交换机有四种类型，选哪种取决于「一条消息要给谁」：</p>

<table><thead><tr><th>类型</th><th>路由规则</th><th>适合的场景</th></tr></thead><tbody><tr><td>direct</td><td>routing key 完全匹配</td><td>点对点，把任务发给指定的一类消费者</td></tr><tr><td>fanout</td><td>忽略 routing key，发给所有绑定的队列</td><td>广播，所有节点都要收到</td></tr><tr><td>topic</td><td>routing key 按通配符匹配，<code>*</code> 匹配一段，<code>#</code> 匹配多段</td><td>按维度灵活订阅，比如按区域、按组件</td></tr><tr><td>headers</td><td>按消息头匹配</td><td>用得很少</td></tr></tbody></table></section>
<section><h2>二、配置下发：用 topic 交换机做定向广播<a href="#二配置下发用-topic-交换机做定向广播"><span>#</span></a></h2><p>下面是按这个场景整理的思路和简化代码，不是当时的原始实现。</p><p>CDN 配置下发的特点是：一次变更，可能要发给全网所有节点，也可能只发给某个区域、某一类组件。用 topic 交换机，把 routing key 设计成「区域.组件」的形式，就能同时满足这两种需求：</p><ul>
<li>每个节点的 agent 建一个自己的持久化队列，按所在区域和运行的组件绑定，比如广东节点绑定 <code>guangdong.nginx</code>、<code>guangdong.ats</code>，再绑定全网广播用的 <code>all.nginx</code>、<code>all.ats</code>；</li>
<li>只改广东节点的 ATS 配置，发 <code>guangdong.ats</code>；</li>
<li>全网的 Nginx 都要改，发 <code>all.nginx</code>；</li>
<li>以后要加一个维度（比如运营商），也只是在 routing key 里多加一段，通配符 <code>*</code>、<code>#</code> 可以按需匹配。</li>
</ul><p>agent 端的处理流程（Python + pika，简化版）：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>import</span><span> json</span></div></div><div><div><div>2</div></div><div><span>import</span><span> pika</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span><span>conn </span><span>=</span><span> pika.</span><span>BlockingConnection</span><span>(pika.</span><span>ConnectionParameters</span><span>(</span></span><span>"mq-host"</span><span>))</span></div></div><div><div><div>5</div></div><div><span><span>ch </span><span>=</span><span> conn.</span><span>channel</span><span>()</span></span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span><span>ch.</span><span>exchange_declare</span><span>(</span></span><span>exchange</span><span>=</span><span>"cdn.config"</span><span>, </span><span>exchange_type</span><span>=</span><span>"topic"</span><span>, </span><span>durable</span><span>=</span><span>True</span><span>)</span></div></div><div><div><div>8</div></div><div><span><span>q </span><span>=</span><span> ch.</span><span>queue_declare</span><span>(</span></span><span>queue</span><span>=</span><span>"agent.node-001"</span><span>, </span><span>durable</span><span>=</span><span>True</span><span>).method.queue</span></div></div><div><div><div>9</div></div><div><span>for</span><span> key </span><span>in</span><span> [</span><span>"guangdong.nginx"</span><span>, </span><span>"guangdong.ats"</span><span>, </span><span>"all.nginx"</span><span>, </span><span>"all.ats"</span><span>]:</span></div></div><div><div><div>10</div></div><div><span><span>    </span></span><span>ch.</span><span>queue_bind</span><span>(</span><span>exchange</span><span>=</span><span>"cdn.config"</span><span>, </span><span>queue</span><span><span>=</span><span>q, </span></span><span>routing_key</span><span><span>=</span><span>key)</span></span></div></div><div><div><div>11</div></div><div>
</div></div><div><div><div>12</div></div><div><span><span>ch.</span><span>basic_qos</span><span>(</span></span><span>prefetch_count</span><span>=</span><span>1</span><span>)  </span><span># 一次只取一条，处理完再取下一条</span></div></div><div><div><div>13</div></div><div>
</div></div><div><div><div>14</div></div><div><span>def</span><span> </span><span>on_message</span><span>(</span><span>ch</span><span>,</span><span><span> </span><span>method</span></span><span>,</span><span><span> </span><span>props</span></span><span>,</span><span><span> </span><span>body</span></span><span>):</span></div></div><div><div><div>15</div></div><div><span><span>    </span></span><span>task </span><span>=</span><span> json.</span><span>loads</span><span>(body)</span></div></div><div><div><div>16</div></div><div><span>    </span><span>try</span><span>:</span></div></div><div><div><div>17</div></div><div><span>        </span><span>if</span><span><span> </span><span>already_applied</span><span>(task[</span></span><span>"version"</span><span>]):     </span><span># 幂等：同一版本配置只应用一次</span></div></div><div><div><div>18</div></div><div><span><span>            </span></span><span>ch.</span><span>basic_ack</span><span>(method.delivery_tag)</span></div></div><div><div><div>19</div></div><div><span>            </span><span>return</span></div></div><div><div><div>20</div></div><div><span><span>        </span></span><span>write_config</span><span>(task[</span><span>"component"</span><span>], task[</span><span>"content"</span><span>])</span></div></div><div><div><div>21</div></div><div><span><span>        </span></span><span>reload_service</span><span>(task[</span><span>"component"</span><span>])         </span><span># 例如 docker restart / nginx -s reload</span></div></div><div><div><div>22</div></div><div><span><span>        </span></span><span>report</span><span>(task[</span><span>"id"</span><span>], </span><span>"success"</span><span>)</span></div></div><div><div><div>23</div></div><div><span><span>        </span></span><span>ch.</span><span>basic_ack</span><span>(method.delivery_tag)         </span><span># 处理成功才确认</span></div></div><div><div><div>24</div></div><div><span>    </span><span>except</span><span> </span><span>Exception</span><span> </span><span>as</span><span> e:</span></div></div><div><div><div>25</div></div><div><span><span>        </span></span><span>report</span><span>(task[</span><span>"id"</span><span>], </span><span>"failed"</span><span>, </span><span>str</span><span>(e))</span></div></div><div><div><div>26</div></div><div><span><span>        </span></span><span>ch.</span><span>basic_nack</span><span>(method.delivery_tag, </span><span>requeue</span><span>=</span><span>False</span><span>)  </span><span># 交给死信队列，避免无限重试</span></div></div><div><div><div>27</div></div><div>
</div></div><div><div><div>28</div></div><div><span><span>ch.</span><span>basic_consume</span><span>(</span></span><span>queue</span><span><span>=</span><span>q, </span></span><span>on_message_callback</span><span><span>=</span><span>on_message)</span></span></div></div><div><div><div>29</div></div><div><span><span>ch.</span><span>start_consuming</span><span>()</span></span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>这段代码里体现了配置下发最关心的三件事：</p><ol>
<li><strong>手动确认（ack）</strong>：改完配置、重启成功之后才 ack。agent 处理到一半挂掉，消息会重新投递，不会丢。</li>
<li><strong>幂等</strong>：重新投递意味着同一条消息可能执行两次。每份配置带一个版本号，已经应用过的版本直接跳过。</li>
<li><strong>结果回报</strong>：中心要知道每个节点到底改没改成功，所以 agent 处理完要上报结果，失败的单独重试或者人工介入。</li>
</ol></section>
<section><h2>三、分布式账单：RabbitMQ + Celery 的链式任务<a href="#三分布式账单rabbitmq--celery-的链式任务"><span>#</span></a></h2><p>账单场景和配置下发不同：不是「所有节点都要收到」，而是「一堆任务，谁空闲谁来做」。这正好是工作队列模式：多个 worker 消费同一个队列，RabbitMQ 按轮询把任务分给它们，<code>prefetch_count</code> 控制每个 worker 手上最多压几个任务，避免慢的 worker 囤积任务。</p><p>直接用 pika 写任务分发、重试、结果收集比较繁琐，所以用了 <a href="https://docs.celeryq.dev/" target="_blank">Celery</a>，RabbitMQ 作为它的 broker。一个客户的出账流程是几个有先后依赖的步骤，用 Celery 的 <code>chain</code> 串起来，上一步的返回值自动作为下一步的输入：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>from</span><span> celery </span><span>import</span><span> Celery, chain</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span><span>app </span><span>=</span><span> </span><span>Celery</span><span>(</span></span><span>"billing"</span><span>, </span><span>broker</span><span>=</span><span>"amqp://user:pass@mq-host//"</span><span>)</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span><span>@app</span><span>.</span><span>task</span></span><span>(</span><span>bind</span><span>=</span><span>True</span><span><span>,</span><span> </span></span><span>max_retries</span><span>=</span><span>3</span><span><span>,</span><span> </span></span><span>default_retry_delay</span><span>=</span><span>60</span><span>)</span></div></div><div><div><div>6</div></div><div><span>def</span><span> </span><span>fetch_usage</span><span>(</span><span>self</span><span>,</span><span><span> </span><span>customer</span></span><span>,</span><span><span> </span><span>month</span></span><span>):</span></div></div><div><div><div>7</div></div><div><span>    </span><span>try</span><span>:</span></div></div><div><div><div>8</div></div><div><span>        </span><span>return</span><span><span> </span><span>query_usage</span><span>(customer, month)      </span></span><span># 拉取域名级带宽、流量打点</span></div></div><div><div><div>9</div></div><div><span>    </span><span>except</span><span> </span><span>TimeoutError</span><span> </span><span>as</span><span> e:</span></div></div><div><div><div>10</div></div><div><span>        </span><span>raise</span><span> </span><span>self</span><span><span>.</span><span>retry</span><span>(</span></span><span>exc</span><span><span>=</span><span>e)                  </span></span><span># 数据源超时，延迟 60 秒重试，最多 3 次</span></div></div><div><div><div>11</div></div><div>
</div></div><div><div><div>12</div></div><div><span><span>@app</span><span>.</span><span>task</span></span></div></div><div><div><div>13</div></div><div><span>def</span><span> </span><span>calc_bill</span><span>(</span><span>usage</span><span>):</span></div></div><div><div><div>14</div></div><div><span>    </span><span>return</span><span><span> </span><span>apply_billing_rules</span><span>(usage)            </span></span><span># 95 峰值、日峰月均、闲忙时等计费方式</span></div></div><div><div><div>15</div></div><div>
</div></div><div><div><div>16</div></div><div><span><span>@app</span><span>.</span><span>task</span></span></div></div><div><div><div>17</div></div><div><span>def</span><span> </span><span>render_and_upload</span><span>(</span><span>bill</span><span>):</span></div></div><div><div><div>18</div></div><div><span>    </span><span>return</span><span><span> </span><span>upload</span><span>(</span><span>render_file</span><span>(bill))             </span></span><span># 按客户要求的格式生成文件并上传</span></div></div><div><div><div>19</div></div><div>
</div></div><div><div><div>20</div></div><div><span><span>@app</span><span>.</span><span>task</span></span></div></div><div><div><div>21</div></div><div><span>def</span><span> </span><span>send_mail</span><span>(</span><span>file_url</span><span>):</span></div></div><div><div><div>22</div></div><div><span><span>    </span></span><span>notify</span><span>(file_url)</span></div></div><div><div><div>23</div></div><div>
</div></div><div><div><div>24</div></div><div><span># 每个客户一条链，所有客户的链并行跑在多台 worker 上</span></div></div><div><div><div>25</div></div><div><span>for</span><span> customer </span><span>in</span><span> customers:</span></div></div><div><div><div>26</div></div><div><span><span>    </span></span><span>chain</span><span>(fetch_usage.</span><span>s</span><span>(customer, </span><span>"2020-10"</span><span><span>), calc_bill.</span><span>s</span><span>(),</span></span></div></div><div><div><div>27</div></div><div><span><span>          </span></span><span>render_and_upload.</span><span>s</span><span>(), send_mail.</span><span>s</span><span>()).</span><span>apply_async</span><span>()</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>几个实际要注意的点：</p><ul>
<li><strong>重试只针对可恢复的错误</strong>：数据源超时可以重试；计费规则配错了，重试多少次都一样，应该直接失败、告警。</li>
<li><strong>每一步都要幂等</strong>：重试意味着同一步可能执行多次，上传文件用固定的文件名覆盖，发邮件前检查是否已发送。</li>
<li><strong>定时校验</strong>：链式任务跑完不代表结果对。当时还有一个定时任务，检查账单数据和源数据是否一致，不一致就重新触发。</li>
</ul></section>
<section><h2>四、消息不丢、不重、不乱序<a href="#四消息不丢不重不乱序"><span>#</span></a></h2><p>这是用消息队列绕不开的三个问题，整理一下各自的手段：</p><p><strong>不丢</strong>要三端一起保证：</p><ul>
<li>生产者：开启 <strong>publisher confirm</strong>，broker 确认收到之后才算发送成功；</li>
<li>broker：交换机、队列声明为 <code>durable</code>，消息设为持久化（<code>delivery_mode=2</code>），重启后不丢；</li>
<li>消费者：关闭自动确认，<strong>处理成功后手动 ack</strong>。</li>
</ul><p><strong>不重</strong>：RabbitMQ 本身只能做到「至少一次」投递，重复是不可避免的，只能靠消费端<strong>幂等</strong>解决，比如用业务 ID 或版本号去重。</p><p><strong>不乱序</strong>：单个队列、单个消费者时消息是有序的；一旦多个消费者并发处理，或者消息被重新投递，顺序就无法保证。配置下发这种场景，与其强求顺序，不如每份配置带上版本号，<strong>只应用比当前更新的版本</strong>，乱序到达的旧版本直接丢弃。</p></section>
<section><h2>五、本地快速启动<a href="#五本地快速启动"><span>#</span></a></h2><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span># 带管理界面的镜像，15672 是 Web 管理端口</span></div></div><div><div><div>2</div></div><div><span>docker</span><span> </span><span>run</span><span> </span><span>-d</span><span> </span><span>--name</span><span> </span><span>rabbitmq</span><span> </span><span>-p</span><span> </span><span>5672:5672</span><span> </span><span>-p</span><span> </span><span>15672:15672</span><span> </span><span>rabbitmq:3-management</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span># 默认的 guest 账号只能从本机登录，远程连接要新建用户</span></div></div><div><div><div>5</div></div><div><span>docker</span><span> </span><span>exec</span><span> </span><span>rabbitmq</span><span> </span><span>rabbitmqctl</span><span> </span><span>add_user</span><span> </span><span>&lt;user&gt;</span><span> </span><span>&lt;password&gt;</span></div></div><div><div><div>6</div></div><div><span>docker</span><span> </span><span>exec</span><span> </span><span>rabbitmq</span><span> </span><span>rabbitmqctl</span><span> </span><span>set_user_tags</span><span> </span><span>&lt;user&gt;</span><span> </span><span>administrator</span></div></div><div><div><div>7</div></div><div><span>docker</span><span> </span><span>exec</span><span> </span><span>rabbitmq</span><span> </span><span>rabbitmqctl</span><span> </span><span>set_permissions</span><span> </span><span>-p</span><span> </span><span>/</span><span> </span><span>&lt;user&gt;</span><span> </span><span>".*"</span><span> </span><span>".*"</span><span> </span><span>".*"</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>注意 <code>set_permissions</code> 的三个参数是正则表达式，分别表示配置、写、读权限，<code>".*"</code> 才是「全部」；只写 <code>"."</code> 匹配的是长度为 1 的资源名。</p></section>
<section><h2>参考<a href="#参考"><span>#</span></a></h2><ul>
<li><a href="https://www.rabbitmq.com/tutorials" target="_blank">RabbitMQ Tutorials</a></li>
<li><a href="https://www.rabbitmq.com/docs/reliability" target="_blank">RabbitMQ 可靠性指南</a></li>
<li><a href="https://docs.celeryq.dev/en/stable/userguide/canvas.html" target="_blank">Celery Canvas：chain、group、chord</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://tracerzzz.cn/posts/scrapy-netease-music-playlist/</id>
      <title type="text">使用scrapy+scrapy-splash爬取网易云音乐歌单</title>
      <published>2018-05-02T08:37:11.000Z</published>
      <updated>2018-05-02T08:37:11.000Z</updated>
      <author><name>tracerZzz</name></author>
      <link rel="alternate" href="https://tracerzzz.cn/posts/scrapy-netease-music-playlist/"/>
      <summary type="text">在使用网易云音乐的时候，你是否想过大家都在听些什么，什么音乐被听得最多，作为一枚程序员，肯定是自己动手丰衣足食啦，那么，这篇文正将会告诉你如何使用scrapy+scrapy-splash来爬取网易云音乐的歌单，然后存入mongodb，通过播放量找到大家听得最多的歌单。</summary>
      <content type="html"><![CDATA[<blockquote><p>这是 2018 年写的旧文。网易云音乐的页面和接口此后变化很大，文中的选择器和地址大概率已经失效，这里只作为 Scrapy + Splash 抓取动态页面的思路参考。</p></blockquote>
<p></p><figure><img alt="2018051515263513332302.png" loading="lazy" width="806" height="424" src="/_astro/2018051515263513332302.BvmZS7HI_ZnpdXS.webp" /><figcaption>2018051515263513332302.png</figcaption></figure><p></p>
<section><h3>创建项目<a href="#创建项目"><span>#</span></a></h3><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>scrapy</span><span> </span><span>startproject</span><span> </span><span>music163Scrapy</span></div></div><div><div><div>2</div></div><div><span>cd</span><span> </span><span>music163Scrapy</span></div></div><div><div><div>3</div></div><div><span>scrapy</span><span> </span><span>genspider</span><span> </span><span>Music163</span><span> </span><span>163.com</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>文件目录如下：</p><p></p><figure><img alt="20180515152636349896977.png" loading="lazy" width="1534" height="862" src="/_astro/20180515152636349896977.BEOsK8gg_Z1G4YAj.webp" /><figcaption>20180515152636349896977.png</figcaption></figure><p></p></section>
<section><h3>Items.py<a href="#itemspy"><span>#</span></a></h3><p>生成项目目录之后就可以开始编写代码啦，首先修改items.py，定义我们爬取网页后收集的字段。</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>import</span><span> scrapy</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span>class</span><span><span> </span><span>Scrapymusic163Item</span><span>(</span><span>scrapy</span><span>.</span><span>Item</span><span>)</span></span><span>:</span></div></div><div><div><div>5</div></div><div><span>    </span><span># define the fields for your item here like:</span></div></div><div><div><div>6</div></div><div><span>    </span><span># name = scrapy.Field()</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>title </span><span>=</span><span> scrapy.</span><span>Field</span><span>()  </span><span># 标题</span></div></div><div><div><div>8</div></div><div><span><span>    </span></span><span>link </span><span>=</span><span> scrapy.</span><span>Field</span><span>()  </span><span># 链接</span></div></div><div><div><div>9</div></div><div><span><span>    </span></span><span>realLink</span><span>=</span><span>scrapy.</span><span>Field</span><span>() </span><span>#完整连接</span></div></div><div><div><div>10</div></div><div><span><span>    </span></span><span>desc </span><span>=</span><span> scrapy.</span><span>Field</span><span>()  </span><span># 简述</span></div></div><div><div><div>11</div></div><div><span><span>    </span></span><span>listenCount </span><span>=</span><span> scrapy.</span><span>Field</span><span>() </span><span>#播放数量</span></div></div><div><div><div>12</div></div><div><span><span>    </span></span><span>posttime </span><span>=</span><span> scrapy.</span><span>Field</span><span>()  </span><span># 发布时间</span></div></div><div><div><div>13</div></div><div><span><span>    </span></span><span>imgUrl</span><span>=</span><span>scrapy.</span><span>Field</span><span>()  </span><span># 歌单封面图片url</span></div></div><div><div><div>14</div></div><div><span>    </span><span># pass</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h3>Music163Spider<a href="#music163spider"><span>#</span></a></h3><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>class</span><span><span> </span><span>Music163Spider</span><span>(</span><span>scrapy</span><span>.</span><span>Spider</span><span>)</span></span><span>:</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span><span>    </span></span><span>name </span><span>=</span><span> </span><span>"music163"</span></div></div><div><div><div>4</div></div><div><span><span>    </span></span><span>allowed_domains </span><span>=</span><span> [</span><span>"163.com"</span><span>]</span></div></div><div><div><div>5</div></div><div><span><span>    </span></span><span>start_urls </span><span>=</span><span> [</span></div></div><div><div><div>6</div></div><div><span>        </span><span>"https://music.163.com/discover/playlist/?order=new"</span><span>,</span></div></div><div><div><div>7</div></div><div><span>        </span><span>"https://music.163.com/#/discover/playlist/"</span></div></div><div><div><div>8</div></div><div>
</div></div><div><div><div>9</div></div><div><span><span>    </span></span><span>]</span></div></div><div><div><div>10</div></div><div><span>    </span><span>def</span><span> </span><span>parse</span><span>(</span><span>self</span><span>,</span><span><span> </span><span>response</span></span><span>):</span></div></div><div><div><div>11</div></div><div><span>        </span><span>for</span><span> url </span><span>in</span><span> </span><span>self</span><span>.start_urls:</span></div></div><div><div><div>12</div></div><div><span>            </span><span>yield</span><span><span> </span><span>SplashRequest</span><span>(</span></span><span>url</span><span><span>=</span><span>url, </span></span><span>callback</span><span>=</span><span>self</span><span>.parseRequest,</span><span>endpoint</span><span>=</span><span>'render.json'</span><span>, </span><span>args</span><span><span>=</span><span>{</span></span><span>'wait'</span><span>: </span><span>1</span><span>,</span><span>'html'</span><span>:</span><span>1</span><span>,</span><span>'iframes'</span><span>: </span><span>1</span><span>,</span><span>'script'</span><span>:</span><span>1</span><span>})</span></div></div><div><div><div>13</div></div><div>
</div></div><div><div><div>14</div></div><div><span>    </span><span>def</span><span> </span><span>parseRequest</span><span>(</span><span>self</span><span>,</span><span>response</span><span>):</span></div></div><div><div><div>15</div></div><div><span><span>        </span></span><span>iframe_html </span><span>=</span><span> response.data[</span><span>'childFrames'</span><span>][</span><span>0</span><span>][</span><span>'html'</span><span>]</span></div></div><div><div><div>16</div></div><div><span><span>        </span></span><span>sel </span><span>=</span><span> parsel.</span><span>Selector</span><span>(iframe_html)</span></div></div><div><div><div>17</div></div><div><span>        </span><span># for se in sel.xpath('//div[@class="n-rcmd"]/div[@class="v-hd2"]/div[@class="tab"]/a'):</span></div></div><div><div><div>18</div></div><div><span>        </span><span>for</span><span> se </span><span>in</span><span><span> sel.</span><span>xpath</span><span>(</span></span><span>"//div[@id='cateListBox']/div[@class='bd']//a"</span><span>)[</span><span>1</span><span>:]:</span></div></div><div><div><div>19</div></div><div><span><span>            </span></span><span>item </span><span>=</span><span> </span><span>Scrapymusic163Item</span><span>()</span></div></div><div><div><div>20</div></div><div><span><span>            </span></span><span>item[</span><span>'title'</span><span><span>] </span><span>=</span><span> se.</span><span>xpath</span><span>(</span></span><span>'text()'</span><span>)[</span><span>0</span><span><span>].</span><span>extract</span><span>()</span></span></div></div><div><div><div>21</div></div><div><span><span>            </span></span><span>item[</span><span>'link'</span><span><span>] </span><span>=</span><span> se.</span><span>xpath</span><span>(</span></span><span>'@href'</span><span>)[</span><span>0</span><span><span>].</span><span>extract</span><span>()</span></span></div></div><div><div><div>22</div></div><div><span><span>            </span></span><span>url </span><span>=</span><span> response.</span><span>urljoin</span><span>(item[</span><span>'link'</span><span>])</span></div></div><div><div><div>23</div></div><div><span>            </span><span>yield</span><span><span> </span><span>SplashRequest</span><span>(url,</span></span><span>self</span><span>.parse_gedan,</span><span>endpoint</span><span>=</span><span>'render.json'</span><span>, </span><span>args</span><span><span>=</span><span>{</span></span><span>'wait'</span><span>: </span><span>2</span><span>,</span><span>'html'</span><span>:</span><span>1</span><span>,</span><span>'iframes'</span><span>: </span><span>1</span><span>,</span><span>'script'</span><span>:</span><span>1</span><span>})</span></div></div><div><div><div>24</div></div><div><span>    </span><span>def</span><span> </span><span>parse_gedan</span><span>(</span><span>self</span><span>,</span><span><span> </span><span>response</span></span><span>):</span></div></div><div><div><div>25</div></div><div><span><span>        </span></span><span>iframe_html </span><span>=</span><span> response.data[</span><span>'childFrames'</span><span>][</span><span>0</span><span>][</span><span>'html'</span><span>]</span></div></div><div><div><div>26</div></div><div><span><span>        </span></span><span>sel </span><span>=</span><span> parsel.</span><span>Selector</span><span>(iframe_html)</span></div></div><div><div><div>27</div></div><div><span>        </span><span>for</span><span> se </span><span>in</span><span><span> sel.</span><span>xpath</span><span>(</span></span><span>'//ul[@id="m-pl-container"]/li'</span><span>):</span></div></div><div><div><div>28</div></div><div><span><span>            </span></span><span>item </span><span>=</span><span> </span><span>Scrapymusic163Item</span><span>()</span></div></div><div><div><div>29</div></div><div><span><span>            </span></span><span>item[</span><span>'title'</span><span><span>] </span><span>=</span><span> se.</span><span>xpath</span><span>(</span></span><span>'p[@class="dec"]/a/text()'</span><span>)[</span><span>0</span><span><span>].</span><span>extract</span><span>()</span></span></div></div><div><div><div>30</div></div><div><span><span>            </span></span><span>item[</span><span>'link'</span><span><span>] </span><span>=</span><span> se.</span><span>xpath</span><span>(</span></span><span>'p[@class="dec"]/a/@href'</span><span>)[</span><span>0</span><span><span>].</span><span>extract</span><span>()</span></span></div></div><div><div><div>31</div></div><div><span><span>            </span></span><span>url</span><span>=</span><span>response.</span><span>urljoin</span><span>(item[</span><span>'link'</span><span>])</span></div></div><div><div><div>32</div></div><div><span><span>            </span></span><span>item[</span><span>'realLink'</span><span><span>]</span><span>=</span><span>url</span></span></div></div><div><div><div>33</div></div><div><span><span>            </span></span><span>item[</span><span>'listenCount'</span><span><span>]</span><span>=</span><span>se.</span><span>xpath</span><span>(</span></span><span>'div/div/span[@class="nb"]/text()'</span><span>)[</span><span>0</span><span><span>].</span><span>extract</span><span>()</span></span></div></div><div><div><div>34</div></div><div><span>            </span><span>if</span><span>(item[</span><span>'listenCount'</span><span>] </span><span>and</span><span> </span><span>"万"</span><span> </span><span>in</span><span> item[</span><span>'listenCount'</span><span>]):</span></div></div><div><div><div>35</div></div><div><span><span>                </span></span><span>item[</span><span>'listenCount'</span><span><span>]</span><span>=</span></span><span>int</span><span>(item[</span><span>'listenCount'</span><span><span>].</span><span>replace</span><span>(</span></span><span>"万"</span><span>,</span><span>"0000"</span><span>))</span></div></div><div><div><div>36</div></div><div><span><span>            </span></span><span>item[</span><span>'listenCount'</span><span><span>]</span><span>=</span></span><span>int</span><span>(item[</span><span>'listenCount'</span><span>])</span></div></div><div><div><div>37</div></div><div><span><span>            </span></span><span>item[</span><span>'imgUrl'</span><span><span>]</span><span>=</span><span>se.</span><span>xpath</span><span>(</span></span><span>'div/img[@class="j-flag"]/@src'</span><span>)[</span><span>0</span><span><span>].</span><span>extract</span><span>()</span></span></div></div><div><div><div>38</div></div><div><span>            </span><span>yield</span><span> item</span></div></div><div><div><div>39</div></div><div><span><span>        </span></span><span>next_pages </span><span>=</span><span> sel.</span><span>xpath</span><span>(</span><span>'//a[@class="zbtn znxt"]'</span><span>)</span></div></div><div><div><div>40</div></div><div>
</div></div><div><div><div>41</div></div><div><span>        </span><span>if</span><span> next_pages:</span></div></div><div><div><div>42</div></div><div><span><span>            </span></span><span>url </span><span>=</span><span> response.</span><span>urljoin</span><span>(next_pages.</span><span>xpath</span><span>(</span><span>'@href'</span><span>)[</span><span>0</span><span><span>].</span><span>extract</span><span>())</span></span></div></div><div><div><div>43</div></div><div><span>            </span><span>yield</span><span><span> </span><span>SplashRequest</span><span>(url,</span></span><span>self</span><span>.parse_gedan,</span><span>endpoint</span><span>=</span><span>'render.json'</span><span>, </span><span>args</span><span><span>=</span><span>{</span></span><span>'wait'</span><span>: </span><span>2</span><span>,</span><span>'html'</span><span>:</span><span>1</span><span>,</span><span>'iframes'</span><span>: </span><span>1</span><span>,</span><span>'script'</span><span>:</span><span>1</span><span>})</span></div></div><div><div><div>44</div></div><div><span>if</span><span> </span><span>__name__</span><span><span> </span><span>==</span><span> </span></span><span>'__main__'</span><span>:</span></div></div><div><div><div>45</div></div><div><span>    </span><span>pass</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>上边这段代码是爬取歌单的关键。这里首先说下爬取的思路，打开网易云的网站，找到歌单连接,歌单分为热门和最新两种，每种又根据风格分为72小类，思路就是先爬取所有种类的连接</p><p></p><figure><img alt="20180515152636458293531.png" loading="lazy" width="1386" height="918" src="/_astro/20180515152636458293531.Cj5gJs3Q_20XTVH.webp" /><figcaption>20180515152636458293531.png</figcaption></figure><p></p><p>再打开每类歌单的网页</p><p></p><figure><img alt="20180515152636639480608.png" loading="lazy" width="1384" height="1152" src="/_astro/20180515152636639480608.DqDkJo4D_Z1Wrgc9.webp" /><figcaption>20180515152636639480608.png</figcaption></figure><p></p><p>从上面的图片中，我们可以看到每首歌的标题,连接，收听次数等信息。</p></section>
<section><h3>scrapy-splash<a href="#scrapy-splash"><span>#</span></a></h3><p>由于网易云的连接嵌套了动态链接的iframe,这里使用了scrapy-splash来动态渲染iframe，相当于让scrapy-splash来执行网页中的js，并将渲染结果返给我们，这样我们就可以继续使用xpath来筛选自己感兴趣的内容了。</p><section><h4>docker 部署scrapy-splash<a href="#docker-部署scrapy-splash"><span>#</span></a></h4><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>docker</span><span> </span><span>pull</span><span> </span><span>scrapinghub/splash</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>docker</span><span> </span><span>run</span><span> </span><span>-p</span><span>  </span><span>8050:8050</span><span> </span><span>scrapinghub/splash</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h4>配置<a href="#配置"><span>#</span></a></h4><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>vim</span><span> </span><span>setting.py</span></div></div></code></pre><div><div></div><div></div></div></figure></div><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>#SPLASH SERVER</span></div></div><div><div><div>2</div></div><div><span><span>SPLASH_URL</span><span> </span><span>=</span><span> </span></span><span>'http://127.0.0.1:8050/render.json'</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span><span>DOWNLOADER_MIDDLEWARES</span><span> </span><span>=</span><span> {</span></span></div></div><div><div><div>5</div></div><div><span>    </span><span>'scrapy_splash.SplashCookiesMiddleware'</span><span>: </span><span>723</span><span>,</span></div></div><div><div><div>6</div></div><div><span>    </span><span>'scrapy_splash.SplashMiddleware'</span><span>: </span><span>725</span><span>,</span></div></div><div><div><div>7</div></div><div><span>    </span><span>'scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware'</span><span>: </span><span>810</span><span>,</span></div></div><div><div><div>8</div></div><div><span>}</span></div></div><div><div><div>9</div></div><div>
</div></div><div><div><div>10</div></div><div>
</div></div><div><div><div>11</div></div><div><span><span>SPIDER_MIDDLEWARES</span><span> </span><span>=</span><span> {</span></span></div></div><div><div><div>12</div></div><div><span>    </span><span>'scrapy_splash.SplashDeduplicateArgsMiddleware'</span><span>: </span><span>100</span><span>,</span></div></div><div><div><div>13</div></div><div><span>}</span></div></div><div><div><div>14</div></div><div>
</div></div><div><div><div>15</div></div><div><span><span>DUPEFILTER_CLASS</span><span> </span><span>=</span><span> </span></span><span>'scrapy_splash.SplashAwareDupeFilter'</span></div></div><div><div><div>16</div></div><div>
</div></div><div><div><div>17</div></div><div><span><span>HTTPCACHE_STORAGE</span><span> </span><span>=</span><span> </span></span><span>'scrapy_splash.SplashAwareFSCacheStorage'</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>详细参见<a href="http://splash.readthedocs.io/en/latest/" target="_blank">scrapy-splash文档</a></p><p>需要注意的是在使用scrapy-splash时需要用SplashRequest来代替scrapy原始的request,这是scrapy-splash对scrapy的request进行了进一步的封装，支持了更多的参数，同时也支持将request的返回配置为不同的格式，如html,json等。这里使用了json返回。</p></section><section><h4>mongodb<a href="#mongodb"><span>#</span></a></h4><p>依赖<a href="https://github.com/sebdah/scrapy-mongodb" target="_blank">scrapy-mongodb</a></p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>ITEM_PIPELINES</span><span> </span><span>=</span><span> {</span></span></div></div><div><div><div>2</div></div><div><span>  </span><span>"scrapy_mongodb.MongoDBPipeline"</span><span>:</span><span>900</span></div></div><div><div><div>3</div></div><div><span>}</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span><span>MONGODB_URI</span><span> </span><span>=</span><span> </span></span><span>'mongodb://root:'</span><span><span>+</span><span>urllib.parse.</span><span>quote</span><span>(</span></span><span>'MyNewPass+@321'</span><span><span>)</span><span>+</span></span><span>'@localhost:27017'</span></div></div><div><div><div>6</div></div><div><span><span>MONGODB_DATABASE</span><span> </span><span>=</span><span> </span></span><span>'scrapy'</span></div></div><div><div><div>7</div></div><div><span># MONGODB_COLLECTION = 'my_items'</span></div></div><div><div><div>8</div></div><div><span># 连接不重复，避免重复爬取</span></div></div><div><div><div>9</div></div><div><span><span>MONGODB_UNIQUE_KEY</span><span> </span><span>=</span><span> </span></span><span>'realLink'</span></div></div><div><div><div>10</div></div><div>
</div></div><div><div><div>11</div></div><div><span><span>MONGODB_SEPARATE_COLLECTIONS</span><span> </span><span>=</span><span> </span></span><span>True</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section></section>
<section><h3>爬取结果<a href="#爬取结果"><span>#</span></a></h3><p></p><figure><img alt="20180515152636644617456.png" loading="lazy" width="1266" height="260" src="/_astro/20180515152636644617456.BjqZx949_XUOMt.webp" /><figcaption>20180515152636644617456.png</figcaption></figure><p></p><p>共爬取了15万左右的歌单。下面是播放次数最多的五个歌单</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>/* 1 */</span></div></div><div><div><div>2</div></div><div><span>{</span></div></div><div><div><div>3</div></div><div><span>    </span><span>"_id"</span><span> : </span><span>ObjectId</span><span>(</span><span>"5aea7a036d5b76bf8d42659d"</span><span>),</span></div></div><div><div><div>4</div></div><div><span>    </span><span>"title"</span><span> : </span><span>"华语速爆新歌"</span><span>,</span></div></div><div><div><div>5</div></div><div><span>    </span><span>"link"</span><span> : </span><span>"/playlist?id=924680166"</span><span>,</span></div></div><div><div><div>6</div></div><div><span>    </span><span>"realLink"</span><span> : </span><span>"https://music.163.com/playlist?id=924680166"</span><span>,</span></div></div><div><div><div>7</div></div><div><span>    </span><span>"listenCount"</span><span> : </span><span>152900000</span><span>,</span></div></div><div><div><div>8</div></div><div><span>    </span><span>"imgUrl"</span><span> : </span><span>"http://p1.music.126.net/Zcv4NgK5T0bd1yMzAFp8Iw==/109951163276364917.jpg?param=140y140"</span></div></div><div><div><div>9</div></div><div><span>}</span></div></div><div><div><div>10</div></div><div>
</div></div><div><div><div>11</div></div><div><span>/* 2 */</span></div></div><div><div><div>12</div></div><div><span>{</span></div></div><div><div><div>13</div></div><div><span>    </span><span>"_id"</span><span> : </span><span>ObjectId</span><span>(</span><span>"5aea78a56d5b76bf8d422179"</span><span>),</span></div></div><div><div><div>14</div></div><div><span>    </span><span>"title"</span><span> : </span><span>"【节奏控】那些超带感的音乐（典藏版）"</span><span>,</span></div></div><div><div><div>15</div></div><div><span>    </span><span>"link"</span><span> : </span><span>"/playlist?id=306397077"</span><span>,</span></div></div><div><div><div>16</div></div><div><span>    </span><span>"realLink"</span><span> : </span><span>"https://music.163.com/playlist?id=306397077"</span><span>,</span></div></div><div><div><div>17</div></div><div><span>    </span><span>"listenCount"</span><span> : </span><span>140930000</span><span>,</span></div></div><div><div><div>18</div></div><div><span>    </span><span>"imgUrl"</span><span> : </span><span>"http://p1.music.126.net/RnOZHM0BNxXuy-RwQQI5BA==/3313928048221849.jpg?param=140y140"</span></div></div><div><div><div>19</div></div><div><span>}</span></div></div><div><div><div>20</div></div><div>
</div></div><div><div><div>21</div></div><div><span>/* 3 */</span></div></div><div><div><div>22</div></div><div><span>{</span></div></div><div><div><div>23</div></div><div><span>    </span><span>"_id"</span><span> : </span><span>ObjectId</span><span>(</span><span>"5aea7fd16d5b76bf8d43b0db"</span><span>),</span></div></div><div><div><div>24</div></div><div><span>    </span><span>"title"</span><span> : </span><span>"【旋律控】超级好听的外文歌"</span><span>,</span></div></div><div><div><div>25</div></div><div><span>    </span><span>"link"</span><span> : </span><span>"/playlist?id=310970433"</span><span>,</span></div></div><div><div><div>26</div></div><div><span>    </span><span>"realLink"</span><span> : </span><span>"https://music.163.com/playlist?id=310970433"</span><span>,</span></div></div><div><div><div>27</div></div><div><span>    </span><span>"listenCount"</span><span> : </span><span>101740000</span><span>,</span></div></div><div><div><div>28</div></div><div><span>    </span><span>"imgUrl"</span><span> : </span><span>"http://p1.music.126.net/2MsstS-M9w5-li0aRy3sUQ==/1380986606815861.jpg?param=140y140"</span></div></div><div><div><div>29</div></div><div><span>}</span></div></div><div><div><div>30</div></div><div>
</div></div><div><div><div>31</div></div><div><span>/* 4 */</span></div></div><div><div><div>32</div></div><div><span>{</span></div></div><div><div><div>33</div></div><div><span>    </span><span>"_id"</span><span> : </span><span>ObjectId</span><span>(</span><span>"5aea95f76d5b76bf8d46289a"</span><span>),</span></div></div><div><div><div>34</div></div><div><span>    </span><span>"title"</span><span> : </span><span>"那些只听前奏就中毒的英文歌"</span><span>,</span></div></div><div><div><div>35</div></div><div><span>    </span><span>"link"</span><span> : </span><span>"/playlist?id=37432514"</span><span>,</span></div></div><div><div><div>36</div></div><div><span>    </span><span>"realLink"</span><span> : </span><span>"https://music.163.com/playlist?id=37432514"</span><span>,</span></div></div><div><div><div>37</div></div><div><span>    </span><span>"listenCount"</span><span> : </span><span>95420000</span><span>,</span></div></div><div><div><div>38</div></div><div><span>    </span><span>"imgUrl"</span><span> : </span><span>"http://p1.music.126.net/mQy3lRj6YJ0TW3fM9v85YA==/6643249256145165.jpg?param=140y140"</span></div></div><div><div><div>39</div></div><div><span>}</span></div></div><div><div><div>40</div></div><div>
</div></div><div><div><div>41</div></div><div><span>/* 5 */</span></div></div><div><div><div>42</div></div><div><span>{</span></div></div><div><div><div>43</div></div><div><span>    </span><span>"_id"</span><span> : </span><span>ObjectId</span><span>(</span><span>"5aea789a6d5b76bf8d42194c"</span><span>),</span></div></div><div><div><div>44</div></div><div><span>    </span><span>"title"</span><span> : </span><span>"【欧美男团】秒杀耳朵系列"</span><span>,</span></div></div><div><div><div>45</div></div><div><span>    </span><span>"link"</span><span> : </span><span>"/playlist?id=117377955"</span><span>,</span></div></div><div><div><div>46</div></div><div><span>    </span><span>"realLink"</span><span> : </span><span>"https://music.163.com/playlist?id=117377955"</span><span>,</span></div></div><div><div><div>47</div></div><div><span>    </span><span>"listenCount"</span><span> : </span><span>71150000</span><span>,</span></div></div><div><div><div>48</div></div><div><span>    </span><span>"imgUrl"</span><span> : </span><span>"http://p1.music.126.net/FJAxNkFoq3dGiS9tz_bGgQ==/3405187512421439.jpg?param=140y140"</span></div></div><div><div><div>49</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div></section>
<section><h3>github地址<a href="#github地址"><span>#</span></a></h3><p><a href="https://github.com/tracerZzz/music163Scrapy" target="_blank">https://github.com/tracerZzz/music163Scrapy</a></p></section>
<section><h3>参考<a href="#参考"><span>#</span></a></h3><p><a href="http://scrapy-chs.readthedocs.io/zh_CN/latest/intro/tutorial.html" target="_blank">scrapy文档</a></p><p><a href="http://splash.readthedocs.io/en/latest/" target="_blank">scrapy-splash文档</a></p><p><a href="https://github.com/sebdah/scrapy-mongodb" target="_blank">scrapy-mongodb</a></p></section>]]></content>
    </entry>
    <entry>
      <id>https://tracerzzz.cn/posts/keepalived-mysql-dual-master/</id>
      <title type="text">Keepalived + MySQL 双主：搭建 Zabbix Proxy 高可用集群</title>
      <published>2018-03-23T04:37:11.000Z</published>
      <updated>2026-10-25T07:30:00.000Z</updated>
      <author><name>tracerZzz</name></author>
      <link rel="alternate" href="https://tracerzzz.cn/posts/keepalived-mysql-dual-master/"/>
      <summary type="text">2018 年在中国电信 CDN 部门为上千台机器的监控系统搭建的 Zabbix Proxy 高可用方案：MySQL 双主互备加 Keepalived 虚拟 IP 漂移。包含完整搭建步骤、故障切换演练方法，以及双主模式里容易踩的坑和现在回头看的改进方向。</summary>
      <content type="html"><![CDATA[<blockquote><p><strong>关于这篇文章</strong>：搭建步骤写于 2018 年，基于当时的 CentOS 7、MySQL 5.7、Zabbix 3.4。2026 年 10 月补充了背景和架构图，新增了「故障切换演练」「双主模式容易踩的坑」「现在回头看」三节，并对文中的 IP 和密码做了脱敏处理。</p></blockquote>
<section><h3>背景<a href="#背景"><span>#</span></a></h3><p>2018 年我在中国电信云计算 CDN 部门，参与搭建天翼高清业务的监控系统，覆盖内容中心和各省中心约一千台机器。监控采用 Zabbix 的 server - proxy - agent 三层架构：agent 把数据上报给所在区域的 proxy，proxy 汇总后再发给 server。</p><p>proxy 是中间这一层的单点：一台 proxy 挂了，它下面整片机器的监控数据就断了。所以需要给 proxy 做高可用，方案是：</p><ul>
<li>两台机器各跑一套 zabbix-proxy 和 MySQL；</li>
<li>两个 MySQL <strong>双主互备</strong>，任意一台写入的数据都会同步到另一台；</li>
<li>Keepalived 维护一个<strong>虚拟 IP（VIP）</strong>，agent 和 server 都只认这个 VIP，主机故障时 VIP 自动漂移到备机。</li>
</ul><div><div><div><div><span><p>节点 B（slave）</p></span></div><div><span><p>节点 A（master）</p></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>双主互相同步 binlog</p></span></div><div><span><p>VRRP 心跳</p></span></div><div><span></span></div><div><span></span></div><div><span><p>正常时</p></span></div><div><span><p>故障切换后</p></span></div><div><span><p>Zabbix Agent（约千台机器）</p></span></div><div><span><p>VIP 192.168.10.100</p></span></div><div><span><p>zabbix-proxy</p></span></div><div><span><p>MySQL</p></span></div><div><span><p>keepalived</p></span></div><div><span><p>zabbix-proxy</p></span></div><div><span><p>MySQL</p></span></div><div><span><p>keepalived</p></span></div><div><span><p>Zabbix Server</p></span></div>
</div><div><div><span><p>节点 B（slave）</p></span></div><div><span><p>节点 A（master）</p></span></div><div><span></span></div><div><span></span></div><div><span></span></div><div><span><p>双主互相同步 binlog</p></span></div><div><span><p>VRRP 心跳</p></span></div><div><span></span></div><div><span></span></div><div><span><p>正常时</p></span></div><div><span><p>故障切换后</p></span></div><div><span><p>Zabbix Agent（约千台机器）</p></span></div><div><span><p>VIP 192.168.10.100</p></span></div><div><span><p>zabbix-proxy</p></span></div><div><span><p>MySQL</p></span></div><div><span><p>keepalived</p></span></div><div><span><p>zabbix-proxy</p></span></div><div><span><p>MySQL</p></span></div><div><span><p>keepalived</p></span></div><div><span><p>Zabbix Server</p></span></div>
</div></div></div><p>软件版本：CentOS 7、MySQL 5.7、Zabbix 3.4（2018 年）。下面先讲两个核心组件，再按步骤搭建。</p></section>
<section><h3>Mysql双主模式<a href="#mysql双主模式"><span>#</span></a></h3><p>mysql双主模式也叫做主主同步或者双主互备，即两台数据库server互为主备。双主模式是在普通的master-slave主备模式下进一步拓展而来的，普通的master-slave主备模式即一主一备，slave主机拉取master主机的Binary log，然后将二进制文件解析为sql语句并完全顺序的执行sql语句所记录的所有操作，从而使slave的数据和master保持一致；双主互备是两台机器互相以对方为master，定时拉取对方的Binary log。双主互备中两台主机都可以进行读写操作，而在普通主备，只有master可以进行写操作，slave只能进行读取操作；</p><p></p><figure><img alt="20180316152118561067778.png" loading="lazy" width="1574" height="1324" src="/_astro/20180316152118561067778.0-1mlIX5_JXaoE.webp" /><figcaption>20180316152118561067778.png</figcaption></figure><p></p><hr /></section>
<section><h3>Keepalived<a href="#keepalived"><span>#</span></a></h3><p><a href="http://www.keepalived.org/doc/" target="_blank">Keepalived</a>是一款高可用软件。keepalived可以让服务器集群共享一个虚拟ip，也就是我们说的vip，同一时间只有一个服务器占有这个虚拟ip，若该服务器不可用或者该服务器的权重小于其他机器，则虚拟IP漂移至另一台服务器并对外提供服务；</p></section>
<section><h3>准备工作<a href="#准备工作"><span>#</span></a></h3><section><h4>机器准备（centos7系统）<a href="#机器准备centos7系统"><span>#</span></a></h4><p>两台 server：<code>192.168.10.11</code>（<strong>master</strong>）、<code>192.168.10.12</code>（<strong>slave</strong>），一个 VIP：<code>192.168.10.100</code>（文中 IP 和密码均为示例）</p><blockquote><p>由于基于vrrp协议，两台server和vip必须处于同一网段</p></blockquote></section><section><h4>安装mysql 并初始化密码<a href="#安装mysql-并初始化密码"><span>#</span></a></h4><p>首先登陆master</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>yum</span><span> </span><span>install</span><span> </span><span>mysql-server</span></div></div><div><div><div>2</div></div><div><span>mysql</span><span> </span><span>-h</span><span> </span><span>localhost</span><span> </span><span>-u</span><span> </span><span>root</span><span> </span><span>-p</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>发现需要输入密码,于是去日志文件里找默认密码</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>vim</span><span> </span><span>/var/log/mysql.log</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>找到下面一行</p><p><code>[Note] A temporary password is generated for root@localhost: &lt;临时密码&gt;</code></p><p><code>&lt;临时密码&gt;</code>是初始化的密码，再次使用<code>mysql -h localhost -u root -p</code>登陆</p><p>使用以下命令重置root密码</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>ALTER</span><span> </span><span>USER</span><span> </span><span>'root'</span><span>@</span><span>'localhost'</span><span> IDENTIFIED </span><span>BY</span><span> </span><span>'&lt;your-password&gt;'</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>登陆slave执行相同的操作</p></section></section>
<section><h3>下载zabbix-proxy，导入表结构<a href="#下载zabbix-proxy导入表结构"><span>#</span></a></h3><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>yum</span><span> </span><span>install</span><span> </span><span>zabbix-proxy-mysql</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>然后根据<a href="https://www.zabbix.com/documentation/3.4/manual/installation/install_from_packages/rhel_centos" target="_blank">zabbix文档</a>提示创建数据库表结构</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>mysql</span><span> </span><span>-h</span><span> </span><span>localhost</span><span> </span><span>-u</span><span> </span><span>root</span><span> </span><span>-p</span></div></div></code></pre><div><div></div><div></div></div></figure></div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>create</span><span> </span><span>database</span><span> </span><span>zabbix_proxy</span><span> </span><span>character</span><span> </span><span>set</span><span> utf8 </span><span>collate</span><span> utf8_bin;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>为zabbix_proxy库创建zabbix用户并授权，然后退出（避免使用root登陆特定的数据库）</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>grant</span><span> all privileges </span><span>on</span><span> zabbix_proxy.* </span><span>to</span><span><span> zabbix</span><span>@localhost</span><span> identified </span></span><span>by</span><span> </span><span>'&lt;zabbix-db-password&gt;'</span><span>;</span></div></div><div><div><div>2</div></div><div><span>quit</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>执行zabbix_proxy自带的sql生成zabbix_proxy表结构</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>zcat</span><span> </span><span>/usr/share/doc/zabbix-proxy-mysql-3.4.1/schema.sql.gz</span><span> |</span><span>mysql</span><span> </span><span>-uroot</span><span> </span><span>-p</span><span> </span><span>zabbix_proxy</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>这个过程需要耐心等待一会，然后登陆数据库查看表数量 一共140个，如果数量不对，需要drop掉zabbix_proxy重新再生成一次。</p><p>最后在slave机器上也执行以上操作。确保两台机器的数据库表结构一致；</p></section>
<section><h3>修改/etc/my.cnf文件，配置同步<a href="#修改etcmycnf文件配置同步"><span>#</span></a></h3><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>vim</span><span> </span><span>/etc/my.cnf</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>在[mysqld]下追加</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>log-bin</span><span><span> </span><span>=</span><span> mysql-bin    </span></span><span>#开启binlog日志</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>binlog_format</span><span><span> </span><span>=</span><span> mixed  </span></span><span>#基于混合模式,mysql Replication 有三种模式，基于语句，基于行，基于混合模式</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>server-id</span><span><span> </span><span>=</span><span> 1          </span></span><span>#server-id master要和slave区分开，master为1 ，slave为2</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span>relay-log</span><span><span> </span><span>=</span><span> relay-bin  </span></span><span># 开启中继日志</span></div></div><div><div><div>8</div></div><div>
</div></div><div><div><div>9</div></div><div><span>relay-log-index</span><span><span> </span><span>=</span><span> slave-relay-bin.index </span></span><span>#中继日志的索引文件</span></div></div><div><div><div>10</div></div><div>
</div></div><div><div><div>11</div></div><div><span>auto-increment-increment</span><span><span> </span><span>=</span><span> 2 </span></span><span>#主键自增的幅度，这个要和机器的数量一致</span></div></div><div><div><div>12</div></div><div>
</div></div><div><div><div>13</div></div><div><span>auto-increment-offset</span><span><span> </span><span>=</span><span> 1  </span></span><span>#从几开始自增，master从1开始自增，每次增加2,即master生成的主键永远为奇数</span></div></div><div><div><div>14</div></div><div>
</div></div><div><div><div>15</div></div><div><span>replicate-wild-do-table</span><span><span>=</span><span>zabbix_proxy.% </span></span><span>#需要同步的表，这里是zabbix_proxy下的所有的表,用通配符%表示</span></div></div><div><div><div>16</div></div><div>
</div></div><div><div><div>17</div></div><div><span>slave_skip_errors</span><span><span> </span><span>=</span><span> 1062,1032,1060  </span></span><span>#同步时跳过的错误编号，1062，1032，1060</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>重启mysql<code>sudo systemctl restart mysqld</code></p><p>重新登陆mysql,为slave机器授予replication权限</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>grant</span><span> replication slave </span><span>on</span><span> *.* </span><span>to</span><span> </span><span>'repl_user'</span><span>@</span><span>'192.168.10.12'</span><span> identified </span><span>by</span><span> </span><span>'&lt;your-password&gt;'</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>查看master当前binlog状态信息</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>show </span><span>master</span><span> </span><span>status</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p></p><figure><img alt="20180319152143914570938.png" loading="lazy" width="1210" height="232" src="/_astro/20180319152143914570938.B2qmn2d-_Hdw4W.webp" /><figcaption>20180319152143914570938.png</figcaption></figure><p></p><p>在slaver机器上指定数据库master为192.168.10.11，并指定日志文件名字和位置，这样slaver就知道从哪里开始进行同步了。</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>change </span><span>master</span><span> </span><span>to</span><span><span> master_host</span><span>=</span></span><span>'192.168.10.11'</span><span><span>,master_user</span><span>=</span></span><span>'repl_user'</span><span><span>,master_password</span><span>=</span></span><span>'&lt;your-password&gt;'</span><span><span>,master_log_file</span><span>=</span></span><span>'mysql-bin.000001'</span><span><span>,  master_log_pos</span><span>=</span></span><span>154</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>开启slaver</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>start</span><span> slave</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>接下来在slave机器上进行同样的操作，给maser授予复制权限，然后查看slave机器的binlog状态信息（每个机器的不一定一样，这里和maser的状态一样）</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>grant</span><span> replication slave </span><span>on</span><span> *.* </span><span>to</span><span> </span><span>'repl_user'</span><span>@</span><span>'192.168.10.11'</span><span> identified </span><span>by</span><span> </span><span>'&lt;your-password&gt;'</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p></p><figure><img alt="20180319152143914570938.png" loading="lazy" width="1210" height="232" src="/_astro/20180319152143914570938.B2qmn2d-_Hdw4W.webp" /><figcaption>20180319152143914570938.png</figcaption></figure><p></p><p>在master机器上指定数据库master为192.168.10.12,并指定日志文件名字和位置</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>change </span><span>master</span><span> </span><span>to</span><span><span> master_host</span><span>=</span></span><span>'192.168.10.12'</span><span><span>,master_user</span><span>=</span></span><span>'repl_user'</span><span><span>,master_password</span><span>=</span></span><span>'&lt;your-password&gt;'</span><span><span>,master_log_file</span><span>=</span></span><span>'mysql-bin.000001'</span><span><span>,  master_log_pos</span><span>=</span></span><span>154</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>开启slaver</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>start</span><span> slave</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h3>防火墙设置<a href="#防火墙设置"><span>#</span></a></h3><p>开启3306端口</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>sudo</span><span> </span><span>firewall-cmd</span><span> </span><span>--zone=public</span><span> </span><span>--add-port=3306/tcp</span><span> </span><span>--permanent</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>sudo</span><span> </span><span>firewall-cmd</span><span> </span><span>--reload</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h3>验证数据库同步<a href="#验证数据库同步"><span>#</span></a></h3><p>在两台机器上分别查看slave状态</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>show slave </span><span>status</span><span>\G;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p></p><figure><img alt="20180322152171000488344.png" loading="lazy" width="926" height="122" src="/_astro/20180322152171000488344.BoWlXClP_TWcl2.webp" /><figcaption>20180322152171000488344.png</figcaption></figure><p></p><p>必须确保两个参数 Slave_IO_Running，Slave_SQL_Running为Yes</p><p>都为yes后创建一个表，测试同步数据</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>DROP</span><span> </span><span>TABLE</span><span> </span><span>IF</span><span> </span><span>EXISTS</span><span> </span><span>`test`</span><span>;</span></div></div><div><div><div>2</div></div><div><span>CREATE</span><span> </span><span>TABLE</span><span> `</span><span>test</span><span>` (</span></div></div><div><div><div>3</div></div><div><span>  </span><span>`id`</span><span> </span><span>int</span><span>(</span><span>11</span><span>) </span><span>NOT NULL</span><span> AUTO_INCREMENT,</span></div></div><div><div><div>4</div></div><div><span>  </span><span>PRIMARY KEY</span><span> (</span><span>`id`</span><span>)</span></div></div><div><div><div>5</div></div><div><span><span>) ENGINE</span><span>=</span><span>InnoDB </span></span><span>DEFAULT</span><span><span> CHARSET</span><span>=</span><span>utf8;</span></span></div></div><div><div><div>6</div></div><div><span>insert</span><span> test </span><span>values</span><span>();</span></div></div><div><div><div>7</div></div><div><span>select</span><span> * </span><span>from</span><span> test;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>在master机器上执行上面的操作,返回结构，</p><p></p><figure><img alt="20180322152171091923913.png" loading="lazy" width="146" height="168" src="/_astro/20180322152171091923913.x3oCZPaR_ZmXBXO.webp" /><figcaption>20180322152171091923913.png</figcaption></figure><p></p><p>在slave上也执行查询语句<code>select * from test;</code>,返回同样的结果。再在master上执行<code>insert test values();</code>返回结果如下</p><p></p><figure><img alt="20180322152171094816591.png" loading="lazy" width="146" height="200" src="/_astro/20180322152171094816591.Bd7dSCz0_1UN0eq.webp" /><figcaption>20180322152171094816591.png</figcaption></figure><p></p><p>可以看到master上的所有自增主键都是奇数，接着在slave机器上执行<code>insert test values();</code>语句，结果为123，slave为偶数，从2开始，说明我们的自增配置已经生效，并且也进行了数据同步；</p></section>
<section><h3>下载安装keepalived<a href="#下载安装keepalived"><span>#</span></a></h3><p><code>yum install keepalived</code>下载keepalived，keepalived只有一个配置文件，默认位于/etc/keepalived/keepalived.conf</p><p>在配置之前先查看本地机器的网卡设置，使用<code>ip a</code>命令,得知当前ip使用的是网卡<strong>eth0</strong>,这个要根据<code>ip a</code>命令执行结果得到。<strong>网卡名称将在接下来的keepalived配置文件和防火墙设置中用到</strong>。</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>vim</span><span> </span><span>/etc/keepalived/keepalived.conf</span></div></div></code></pre><div><div></div><div></div></div></figure></div><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>! </span><span>Configuration</span><span> File for keepalived</span></div></div><div><div><div>2</div></div><div><span>global_defs</span><span> {</span></div></div><div><div><div>3</div></div><div><span>   </span><span>router_id</span><span> mysql-1</span></div></div><div><div><div>4</div></div><div><span>}</span></div></div><div><div><div>5</div></div><div><span>#group</span></div></div><div><div><div>6</div></div><div><span>vrrp_sync_group</span><span> VG1 {</span></div></div><div><div><div>7</div></div><div><span>      </span><span>group</span><span> {</span></div></div><div><div><div>8</div></div><div><span>              </span><span>HA_1</span></div></div><div><div><div>9</div></div><div><span><span>               </span></span><span>}</span></div></div><div><div><div>10</div></div><div><span><span>            </span></span><span>}</span></div></div><div><div><div>11</div></div><div>
</div></div><div><div><div>12</div></div><div><span>vrrp_script</span><span> check_mysql {</span></div></div><div><div><div>13</div></div><div><span>script</span><span> /etc/keepalived/bin/check.sh</span></div></div><div><div><div>14</div></div><div><span>interval</span><span> </span><span>2</span><span>   </span><span>#执行间隔</span></div></div><div><div><div>15</div></div><div><span>weight</span><span> </span><span>20</span><span>    </span><span>#权重</span></div></div><div><div><div>16</div></div><div><span>}</span></div></div><div><div><div>17</div></div><div><span>vrrp_instance</span><span> HA_1 {</span></div></div><div><div><div>18</div></div><div><span>   <span> </span></span><span>state </span><span>BACKUP  </span><span>#两个节点最好都设置成BACKUP模式，避免因为意外情况下（比如脑裂）相互抢占导致往两个节点写入相同数据而引发冲突；</span></div></div><div><div><div>19</div></div><div><span><span>    </span></span><span>interface eth0  </span><span>#网卡</span></div></div><div><div><div>20</div></div><div><span><span>    </span></span><span>virtual_router_id </span><span>51</span><span> </span><span>#两台机器要一致</span></div></div><div><div><div>21</div></div><div><span><span>    </span></span><span>priority </span><span>100</span><span>         </span><span>#初始权重，master机器配置为100，slave配置为90</span></div></div><div><div><div>22</div></div><div><span><span>    </span></span><span>nopreempt            </span><span>#不抢占，当slave机器正常工作时，master恢复正常后不抢占slave的vip</span></div></div><div><div><div>23</div></div><div><span><span>    </span></span><span>advert_int </span><span>1</span><span>         </span><span>#检查间隔，两台机器一致</span></div></div><div><div><div>24</div></div><div><span><span>    </span></span><span>authentication {</span></div></div><div><div><div>25</div></div><div><span><span>        </span></span><span>auth_type PASS</span></div></div><div><div><div>26</div></div><div><span><span>        </span></span><span>auth_pass </span><span>1111</span><span>   </span><span>#两台一致</span></div></div><div><div><div>27</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>28</div></div><div><span><span>    </span></span><span>virtual_ipaddress {</span></div></div><div><div><div>29</div></div><div><span>      </span><span>192.168.10.100</span><span>  </span><span>#vip</span></div></div><div><div><div>30</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>31</div></div><div>
</div></div><div><div><div>32</div></div><div><span><span>    </span></span><span>track_script {</span></div></div><div><div><div>33</div></div><div><span><span>        </span></span><span>check_mysql</span></div></div><div><div><div>34</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>35</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><section><h4>keepalived的配置文件说明<a href="#keepalived的配置文件说明"><span>#</span></a></h4><p>vrrp_sync_group的应用场景为：如果路由有2个网段，一个内网，一个外网，每个网段开启一个VRRP实例，假设VRRP配置为检查内网，那么当外网出现问题 时，VRRPD会认为自己是健康的，则不会发送Master和Backup的切换，从而导致问题，Sync Group可以把两个实例都放入Sync Group，这样的话，Group 里任何一个实例出现问题都会发生切换。在这里其实并没有用到group，因为我们只设置了一个实例。</p><p>vrrp_instance配置了检测脚本，vip等信息。两台机器的配置基本一致，除了priority参数（master设置为100，slave设置为90）。当master机器keepalivde启动后，将会去执行check.sh，每两秒执行一次，如果执行结果返回零，将在初始权重上加上20，maste的权重将达到120，同理，slave的权重为110（90+20）。这时，vip将漂移到master机器上。如果master上的check.sh执行结果返回1,那么master的权重将不会加20，那么master的权重为100，小于110，此时vip将漂移到slave机器上。keepalived就是这样根据权重的大小进行vip漂移的。另外，需要注意的是，<strong>检测脚本的权重一定要大于两台机器的初始权重</strong>，20&gt;(100-90),因为只有这样，检测脚本检测到服务异常时才能影响到权重的总和。</p><p>vrrp_script配置了检测脚本和被检测的服务的权重，以及检测频率等。这里的关键是check.sh的实现。这个脚本这里主要是用来检测mysql是否正常服务，而正常服务的检测可以很简单，也可以很复杂，比如简单的检测mysqld服务是否正常启动，复杂的可以用mysql命令登陆数据库，执行sql语句，查看mysql的状态，进行判断。这里我们只是简单的检测了mysqld服务是否正常启动；</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>#!/bin/bash</span></div></div><div><div><div>2</div></div><div><span>killall</span><span> </span><span>-0</span><span> </span><span>mysqld</span><span> </span><span>zabbix_proxy</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>试了了killall命令，如果没有该命令需要安装<code>sudo yum install psmisc -y</code>,<code>-0</code> 是检测服务正常运行的信号量，如果正常运行返回0，不正常返回1，支持传入多个服务；</p><p>同时需要注意的是记得为该脚本授权<code>chmod +x /etc/keepalived/bin/check.sh</code>;</p><p><code>sudo vim check.sh</code></p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>#!/bin/bash</span></div></div><div><div><div>2</div></div><div><span>sudo</span><span> </span><span>killall</span><span> </span><span>-0</span><span> </span><span>mysqld</span><span> </span><span>zabbix-proxy</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h4>Keepalived防火墙设置<a href="#keepalived防火墙设置"><span>#</span></a></h4><p>keepalived需要执行下面两条语句，对防火墙进行操作，才能确保两台keepalived能够进行选举,如果没有进行防火墙设置，则会出现两台机器都绑定了vip（脑裂）,原因就是两台机器没法进行通信，无法找到真正的master;</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>sudo</span><span> </span><span>firewall-cmd</span><span> </span><span>--direct</span><span> </span><span>--permanent</span><span> </span><span>--add-rule</span><span> </span><span>ipv4</span><span> </span><span>filter</span><span> </span><span>INPUT</span><span> </span><span>0</span><span> </span><span>--in-interface</span><span> </span><span>eth0</span><span> </span><span>--destination</span><span> </span><span>224.0.0.18</span><span> </span><span>--protocol</span><span> </span><span>vrrp</span><span> </span><span>-j</span><span> </span><span>ACCEPT</span></div></div><div><div><div>2</div></div><div><span>sudo</span><span> </span><span>firewall-cmd</span><span> </span><span>--direct</span><span> </span><span>--permanent</span><span> </span><span>--add-rule</span><span> </span><span>ipv4</span><span> </span><span>filter</span><span> </span><span>OUT</span><span> </span><span>0</span><span> </span><span>--out-interface</span><span> </span><span>eth0</span><span> </span><span>--destination</span><span> </span><span>224.0.0.18</span><span> </span><span>--protocol</span><span> </span><span>vrrp</span><span> </span><span>-j</span><span> </span><span>ACCEPT</span></div></div><div><div><div>3</div></div><div><span>sudo</span><span> </span><span>firewall-cmd</span><span> </span><span>--reload</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section></section>
<section><h3>启动 zabbix-proxy<a href="#启动-zabbix-proxy"><span>#</span></a></h3><section><h4>修改配置文件<a href="#修改配置文件"><span>#</span></a></h4><p><code>vim /etc/zabbix/zabbix_proxy.conf</code></p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>ProxyMode</span><span><span>=</span><span>1</span></span></div></div><div><div><div>2</div></div><div><span>Server</span><span><span>=</span><span>&lt;zabbix-server-ip&gt;</span></span></div></div><div><div><div>3</div></div><div><span>Hostname</span><span><span>=</span><span>192.168.10.100  </span></span><span>#vip</span></div></div><div><div><div>4</div></div><div><span>LogFile</span><span><span>=</span><span>/var/log/zabbix/zabbix_proxy.log</span></span></div></div><div><div><div>5</div></div><div><span>LogFileSize</span><span><span>=</span><span>0</span></span></div></div><div><div><div>6</div></div><div><span>PidFile</span><span><span>=</span><span>/var/run/zabbix/zabbix_proxy.pid</span></span></div></div><div><div><div>7</div></div><div><span>SocketDir</span><span><span>=</span><span>/var/run/zabbix</span></span></div></div><div><div><div>8</div></div><div><span>DBHost</span><span><span>=</span><span>localhost</span></span></div></div><div><div><div>9</div></div><div><span>DBName</span><span><span>=</span><span>zabbix_proxy</span></span></div></div><div><div><div>10</div></div><div><span>DBUser</span><span><span>=</span><span>zabbix</span></span></div></div><div><div><div>11</div></div><div><span>DBPassword</span><span><span>=</span><span>&lt;zabbix-db-password&gt;</span></span></div></div><div><div><div>12</div></div><div><span>HeartbeatFrequency</span><span><span>=</span><span>10</span></span></div></div><div><div><div>13</div></div><div><span>ConfigFrequency</span><span><span>=</span><span>60</span></span></div></div><div><div><div>14</div></div><div><span>DataSenderFrequency</span><span><span>=</span><span>5</span></span></div></div><div><div><div>15</div></div><div><span>StartPollers</span><span><span>=</span><span>50</span></span></div></div><div><div><div>16</div></div><div><span>StartPollersUnreachable</span><span><span>=</span><span>10</span></span></div></div><div><div><div>17</div></div><div><span>StartTrappers</span><span><span>=</span><span>10</span></span></div></div><div><div><div>18</div></div><div><span>StartPingers</span><span><span>=</span><span>10</span></span></div></div><div><div><div>19</div></div><div><span>SNMPTrapperFile</span><span><span>=</span><span>/var/log/snmptrap/snmptrap.log</span></span></div></div><div><div><div>20</div></div><div><span>CacheSize</span><span><span>=</span><span>8G</span></span></div></div><div><div><div>21</div></div><div><span>StartDBSyncers</span><span><span>=</span><span>10</span></span></div></div><div><div><div>22</div></div><div><span>HistoryCacheSize</span><span><span>=</span><span>2G</span></span></div></div><div><div><div>23</div></div><div><span>HistoryIndexCacheSize</span><span><span>=</span><span>2G</span></span></div></div><div><div><div>24</div></div><div><span>Timeout</span><span><span>=</span><span>30</span></span></div></div><div><div><div>25</div></div><div><span>#DebugLevel=4</span></div></div><div><div><div>26</div></div><div><span>#ExternalScripts=/usr/lib/zabbix/externalscripts</span></div></div><div><div><div>27</div></div><div><span>#LogSlowQueries=3000</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>#开启端口</span></div></div><div><div><div>2</div></div><div><span>sudo</span><span> </span><span>firewall-cmd</span><span> </span><span>--zone=public</span><span> </span><span>--add-port=10051/tcp</span><span> </span><span>--permanent</span></div></div><div><div><div>3</div></div><div><span>sudo</span><span> </span><span>firewall-cmd</span><span> </span><span>--reload</span></div></div><div><div><div>4</div></div><div><span>#启动zabbix-proxy</span></div></div><div><div><div>5</div></div><div><span>systemctl</span><span> </span><span>start</span><span> </span><span>zabbix-proxy</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h4>开机自动启动<a href="#开机自动启动"><span>#</span></a></h4><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>systemctl</span><span> </span><span>enable</span><span> </span><span>zabbix-proxy</span></div></div><div><div><div>2</div></div><div><span>systemctl</span><span> </span><span>enable</span><span> </span><span>mysqld</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section></section>
<section><h3>测试方法<a href="#测试方法"><span>#</span></a></h3><p>1.查看vip绑定情况 ip a</p><p>2.通过连接vip的mysql，</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>mysql</span><span> </span><span>-h</span><span> </span><span>192.168.10.100</span><span> </span><span>-uroot</span><span> </span><span>-p</span></div></div></code></pre><div><div></div><div></div></div></figure></div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>show variables </span><span>like</span><span> </span><span>"%hostname%"</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>查看返回的hostaname,就知道了当前连接是哪台机器</p></section>
<section><h3>故障切换演练<a href="#故障切换演练"><span>#</span></a></h3><p>搭完一定要演练，不然真出故障时才发现切不过去。演练按这个顺序做：</p><ol>
<li><strong>确认初始状态</strong>：在两台机器上执行 <code>ip a</code>，VIP 应该只绑在 master 上；通过 VIP 连 MySQL，执行 <code>show variables like 'hostname'</code>，确认连的是 master。</li>
<li><strong>停 master 的 MySQL</strong>：<code>systemctl stop mysqld</code>。检测脚本返回非 0，master 权重降到 100，低于 slave 的 110，VIP 应在几秒内漂到 slave。再通过 VIP 查一次 hostname 确认。</li>
<li><strong>验证数据不丢</strong>：切换前后各写几条测试数据，恢复 master 后检查两边的 <code>show slave status</code>，两个线程都是 Yes，数据一致。</li>
<li><strong>验证不抢占</strong>：恢复 master 后，因为配置了 <code>nopreempt</code>，VIP 应该留在 slave 上，不会来回漂。</li>
<li><strong>模拟网络隔离</strong>：用防火墙临时屏蔽 VRRP 报文，观察是否出现两台机器同时持有 VIP（脑裂），确认告警能发出来。</li>
</ol></section>
<section><h3>双主模式容易踩的坑<a href="#双主模式容易踩的坑"><span>#</span></a></h3><p><strong>1. 两边同时写，主键冲突。</strong> 用 <code>auto-increment-increment = 2</code> 和不同的 <code>auto-increment-offset</code>，让一台只生成奇数主键、另一台只生成偶数主键。但这只解决自增主键，唯一索引冲突、同一行被两边同时修改的问题依然存在。所以双主虽然两边都能写，<strong>实际使用时最好只往 VIP 所在的那台写</strong>，另一台只做热备。</p><p><strong>2. <code>slave_skip_errors</code> 会掩盖数据不一致。</strong> 文中为了让同步不中断，跳过了 1062（主键重复）、1032（记录不存在）等错误。这在监控数据这种可以容忍少量丢失的场景能用，但它的代价是两边数据悄悄地不一致，<strong>业务数据库千万不要这样配</strong>。</p><p><strong>3. 检测脚本太简单。</strong> <code>killall -0</code> 只能判断进程还在，进程活着但 MySQL 卡死、复制线程已经停了，它都发现不了。更稳妥的检测是真正登录执行一条查询，并检查 <code>Slave_IO_Running</code> 和 <code>Slave_SQL_Running</code>。</p><p><strong>4. 脑裂。</strong> 两台机器之间 VRRP 报文不通时，都会认为对方挂了，各自绑上 VIP。防火墙放行 VRRP（协议号 112，组播地址 224.0.0.18）是最基本的；两边都设为 <code>BACKUP</code> 加 <code>nopreempt</code> 可以减少来回抢占；条件允许的话再加一条独立的心跳链路。</p><p><strong>5. 复制延迟。</strong> 主库写入压力大、大事务、单线程回放都会导致从库跟不上。切换发生时，延迟期间的数据就可能丢失。MySQL 5.7 起可以开启基于组提交的并行复制（<code>slave_parallel_type = LOGICAL_CLOCK</code>）缓解。</p></section>
<section><h3>现在回头看<a href="#现在回头看"><span>#</span></a></h3><p>这套方案在 2018 年是很典型的做法，简单、好理解。放到现在，我会做几处改进：</p><ul>
<li>用 <strong>GTID</strong> 代替手动指定 binlog 文件名和位置，切换和重建复制都简单很多；</li>
<li>更严格的一致性需求，考虑 <strong>MySQL Group Replication</strong> 或者 Orchestrator 这类自动故障转移工具，而不是靠 Keepalived 判断数据库状态；</li>
<li>如果在云上，直接用托管数据库的高可用版本，大多数云环境也不支持 VRRP 组播漂移 VIP。</li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://tracerzzz.cn/posts/nginx-usage/</id>
      <title type="text">Nginx 与 OpenResty：从编译调优到 CDN 边缘的 Lua 定制</title>
      <published>2018-01-15T08:37:11.000Z</published>
      <updated>2026-10-25T07:00:00.000Z</updated>
      <author><name>tracerZzz</name></author>
      <link rel="alternate" href="https://tracerzzz.cn/posts/nginx-usage/"/>
      <summary type="text">我在电信、百度做 CDN 时和 Nginx 打交道最多。这篇从编译、常用命令、性能调优讲起，重点讲 OpenResty 的请求处理阶段，以及在 CDN 边缘用 Lua 实现防盗链、URI 改写和回源 AWS S3 V4 签名鉴权的思路。</summary>
      <content type="html"><![CDATA[<blockquote><p><strong>关于这篇文章</strong>：最初写于 2018 年，当时只是一份 Nginx 编译和常用命令的记录。2026 年 10 月重写：第一到三节保留了当年的内容（压测数据也是 2018 年的），并修正了过时的配置；第四到六节是新增的，来自在中国电信、百度云 CDN 工作期间做 Nginx + Lua 定制开发的经验。</p></blockquote>
<section><h2>一、编译安装<a href="#一编译安装"><span>#</span></a></h2><p>需要第三方模块（比如 <code>nginx-rtmp-module</code>）时，只能从源码编译。以 CentOS 7 为例：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span># 依赖：正则（rewrite、location 正则匹配）、zlib（gzip）、openssl（https）</span></div></div><div><div><div>2</div></div><div><span>yum</span><span> </span><span>install</span><span> </span><span>-y</span><span> </span><span>gcc</span><span> </span><span>pcre-devel</span><span> </span><span>zlib-devel</span><span> </span><span>openssl-devel</span></div></div><div><div><div>3</div></div><div>
</div></div><div><div><div>4</div></div><div><span>wget</span><span> </span><span>http://nginx.org/download/nginx-1.12.2.tar.gz</span></div></div><div><div><div>5</div></div><div><span>tar</span><span> </span><span>zxvf</span><span> </span><span>nginx-1.12.2.tar.gz</span><span> &amp;&amp; </span><span>cd</span><span> </span><span>nginx-1.12.2</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span>./configure</span><span> </span><span>--prefix=/usr/local/nginx</span><span> </span><span>\</span></div></div><div><div><div>8</div></div><div><span>  </span><span>--with-http_ssl_module</span><span> </span><span>\</span></div></div><div><div><div>9</div></div><div><span>  </span><span>--with-debug</span><span> </span><span>\</span></div></div><div><div><div>10</div></div><div><span>  </span><span>--add-module=../nginx-rtmp-module</span><span>   </span><span># 按需添加第三方模块</span></div></div><div><div><div>11</div></div><div>
</div></div><div><div><div>12</div></div><div><span>make</span><span> &amp;&amp; </span><span>make</span><span> </span><span>install</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>现在更推荐直接用 <a href="https://openresty.org/" target="_blank">OpenResty</a>：它是 Nginx 加上 LuaJIT 和一组常用 Lua 库的发行版，后面的 Lua 定制都基于它。Lua 第三方库可以用 OpenResty 自带的 <code>opm</code> 安装，或者用 LuaRocks 指定 OpenResty 的 LuaJIT 编译安装：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>./configure</span><span> </span><span>--with-lua=/usr/local/openresty/luajit</span><span> </span><span>\</span></div></div><div><div><div>2</div></div><div><span>  </span><span>--with-lua-include=/usr/local/openresty/luajit/include/luajit-2.1/</span></div></div><div><div><div>3</div></div><div><span>make</span><span> &amp;&amp; </span><span>make</span><span> </span><span>install</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>二、常用命令与 gzip<a href="#二常用命令与-gzip"><span>#</span></a></h2><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>nginx</span><span> </span><span>-t</span><span>          </span><span># 检查配置语法，改完配置先跑这个</span></div></div><div><div><div>2</div></div><div><span>nginx</span><span> </span><span>-s</span><span> </span><span>reload</span><span>   </span><span># 平滑重载：新 worker 接新请求，老 worker 处理完再退出</span></div></div><div><div><div>3</div></div><div><span>nginx</span><span> </span><span>-s</span><span> </span><span>quit</span><span>     </span><span># 优雅退出</span></div></div><div><div><div>4</div></div><div><span>nginx</span><span> </span><span>-V</span><span>          </span><span># 查看编译参数和已编译的模块</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>gzip 配置。当年的版本把 jpeg、png 也加进了 <code>gzip_types</code>，这是个常见错误：图片本身已经压缩过，再 gzip 只会白白消耗 CPU。</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>gzip </span><span>on</span><span>;</span></div></div><div><div><div>2</div></div><div><span>gzip_min_length </span><span>1k</span><span>;          </span><span># 太小的响应压缩不划算</span></div></div><div><div><div>3</div></div><div><span>gzip_comp_level </span><span>4</span><span>;           </span><span># 1-9，超过 5 收益很小、CPU 开销明显</span></div></div><div><div><div>4</div></div><div><span>gzip_types </span><span>text/plain text/css application/javascript application/json application/xml;</span></div></div><div><div><div>5</div></div><div><span>gzip_vary </span><span>on</span><span>;                </span><span># 加 Vary: Accept-Encoding，避免缓存把压缩版给不支持的客户端</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>三、性能调优：一次简单的压测<a href="#三性能调优一次简单的压测"><span>#</span></a></h2><p>2018 年在一台 4 核 2G 的虚拟机上，用 webbench 对一个静态文件做过压测。Nginx 关键配置：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>worker_processes </span><span> auto;      </span><span># 等于 CPU 核数，当时手写的是 4</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>events</span><span> {</span></div></div><div><div><div>4</div></div><div><span>   <span> </span></span><span>worker_connections </span><span> </span><span>1024</span><span>;  </span><span># 单个 worker 的最大连接数</span></div></div><div><div><div>5</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div>

<table><thead><tr><th>并发客户端</th><th>持续时间</th><th>成功请求数</th><th>每秒请求数</th></tr></thead><tbody><tr><td>500</td><td>10 s</td><td>164,548</td><td>16,454</td></tr><tr><td>1,000</td><td>10 s</td><td>198,593</td><td>19,859</td></tr><tr><td>2,000</td><td>10 s</td><td>190,463</td><td>19,046</td></tr><tr><td>5,000</td><td>10 s</td><td>182,662</td><td>18,266</td></tr><tr><td>10,000</td><td>10 s</td><td>161,927</td><td>16,192</td></tr></tbody></table><p>几个结论：</p><ul>
<li>吞吐在 1000 并发左右到顶，约 2 万 QPS，再加并发反而下降。说明这台机器已经到了处理上限，继续加并发只会增加连接管理和调度的开销。</li>
<li><code>worker_processes × worker_connections</code> 是理论最大连接数，并发超过它会直接报错；同时要调高系统的文件句柄限制（<code>ulimit -n</code> 和 <code>worker_rlimit_nofile</code>）。</li>
<li>这只是静态小文件的结果。真实 CDN 节点上，磁盘 IO、TLS 握手、回源才是大头，不能用这个数字去估容量。</li>
</ul><p>webbench 现在已经很少用了，同类压测更推荐 <a href="https://github.com/wg/wrk" target="_blank">wrk</a>，它支持 Lua 脚本构造请求。</p></section>
<section><h2>四、OpenResty 的请求处理阶段<a href="#四openresty-的请求处理阶段"><span>#</span></a></h2><p>Nginx 把一个请求的处理分成多个阶段，OpenResty 允许在每个阶段挂 Lua 代码。写定制逻辑之前，先想清楚它该放在哪个阶段：</p>

<table><thead><tr><th>阶段</th><th>指令</th><th>典型用途</th></tr></thead><tbody><tr><td>rewrite</td><td><code>rewrite_by_lua_block</code></td><td>改写 URI、参数</td></tr><tr><td>access</td><td><code>access_by_lua_block</code></td><td>鉴权、防盗链、限流，不通过就 <code>ngx.exit(403)</code></td></tr><tr><td>content</td><td><code>content_by_lua_block</code></td><td>直接生成响应（不走 proxy_pass 时）</td></tr><tr><td>header_filter</td><td><code>header_filter_by_lua_block</code></td><td>改响应头，比如加 CORS、改缓存头</td></tr><tr><td>body_filter</td><td><code>body_filter_by_lua_block</code></td><td>改响应体，流式处理</td></tr><tr><td>log</td><td><code>log_by_lua_block</code></td><td>统计、打点，不影响响应时间</td></tr></tbody></table><p>在 CDN 节点上，客户定制几乎都落在 rewrite 和 access 两个阶段：请求进来，先改写、再鉴权，通过之后才查缓存或者回源。</p></section>
<section><h2>五、CDN 边缘的 Lua 定制实践<a href="#五cdn-边缘的-lua-定制实践"><span>#</span></a></h2><p>在百度云 CDN 时，我参与过 Nginx 层的客户定制开发。下面挑三类典型需求，用简化版代码说明原理（生产代码还要处理配置下发、异常和监控，这里省略）。</p><section><h3>1. 防盗链：Referer 校验 + 带时效的签名 URL<a href="#1-防盗链referer-校验--带时效的签名-url"><span>#</span></a></h3><p>最简单的防盗链是 Nginx 自带的 Referer 白名单，但 Referer 可以伪造。更可靠的是<strong>签名 URL</strong>：客户的业务服务器用密钥给 URL 签名并带上过期时间，CDN 节点用同一个密钥校验。</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>location</span><span> /video/ {</span></div></div><div><div><div>2</div></div><div><span>    </span><span>access_by_lua_block</span><span> {</span></div></div><div><div><div>3</div></div><div><span>        </span><span>local</span><span> </span><span>args</span><span> = </span><span>ngx</span><span>.req.</span><span>get_uri_args</span><span>()</span></div></div><div><div><div>4</div></div><div><span>        </span><span>local</span><span> </span><span>t</span><span>, </span><span>sign</span><span> = </span><span>tonumber</span><span>(</span><span>args</span><span>.t), </span><span>args</span><span>.sign</span></div></div><div><div><div>5</div></div><div><span>        </span><span>if</span><span><span> </span><span>not</span><span> </span></span><span>t</span><span><span> </span><span>or</span><span> </span><span>not</span><span> </span></span><span>sign</span><span> </span><span>then</span></div></div><div><div><div>6</div></div><div><span>            </span><span>return</span><span> </span><span>ngx</span><span>.</span><span>exit</span><span>(</span><span>ngx</span><span>.HTTP_FORBIDDEN)</span></div></div><div><div><div>7</div></div><div><span>        </span><span>end</span></div></div><div><div><div>8</div></div><div><span>        </span><span>-- 过期检查</span></div></div><div><div><div>9</div></div><div><span>        </span><span>if</span><span> </span><span>t</span><span> &lt; </span><span>ngx</span><span>.</span><span>time</span><span>() </span><span>then</span></div></div><div><div><div>10</div></div><div><span>            </span><span>return</span><span> </span><span>ngx</span><span>.</span><span>exit</span><span>(</span><span>ngx</span><span>.HTTP_FORBIDDEN)</span></div></div><div><div><div>11</div></div><div><span>        </span><span>end</span></div></div><div><div><div>12</div></div><div><span>        </span><span>-- 签名 = md5(密钥 + 路径 + 过期时间)，密钥按域名配置下发</span></div></div><div><div><div>13</div></div><div><span>        </span><span>local</span><span> </span><span>secret</span><span> = </span><span>ngx</span><span>.var.auth_secret</span></div></div><div><div><div>14</div></div><div><span>        </span><span>if</span><span> </span><span>ngx</span><span>.</span><span>md5</span><span>(</span><span>secret</span><span> .. </span><span>ngx</span><span>.var.uri .. </span><span>t</span><span>) ~= </span><span>sign</span><span> </span><span>then</span></div></div><div><div><div>15</div></div><div><span>            </span><span>return</span><span> </span><span>ngx</span><span>.</span><span>exit</span><span>(</span><span>ngx</span><span>.HTTP_FORBIDDEN)</span></div></div><div><div><div>16</div></div><div><span>        </span><span>end</span></div></div><div><div><div>17</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>18</div></div><div><span>   <span> </span></span><span>proxy_pass </span><span>http://origin;</span></div></div><div><div><div>19</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>注意：签名参数要从缓存 key 里去掉，否则同一个文件每个签名都是一份缓存，命中率会很难看。</p></section><section><h3>2. URI 改写<a href="#2-uri-改写"><span>#</span></a></h3><p>比如客户把旧路径迁移到新路径，或者要把带版本号的 URL 归一成同一份缓存：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>rewrite_by_lua_block</span><span> {</span></div></div><div><div><div>2</div></div><div><span>    </span><span>-- /static/v123/app.js -&gt; /static/app.js</span></div></div><div><div><div>3</div></div><div><span>    </span><span>local</span><span> </span><span>uri</span><span>, </span><span>n</span><span> = </span><span>ngx</span><span>.re.</span><span>sub</span><span>(</span><span>ngx</span><span>.var.uri, </span><span>[[^/static/v\d+/]]</span><span>, </span><span>"/static/"</span><span>, </span><span>"jo"</span><span>)</span></div></div><div><div><div>4</div></div><div><span>    </span><span>if</span><span> </span><span>n</span><span> &gt; </span><span>0</span><span> </span><span>then</span></div></div><div><div><div>5</div></div><div><span>        </span><span>ngx</span><span>.req.</span><span>set_uri</span><span>(</span><span>uri</span><span>)</span></div></div><div><div><div>6</div></div><div><span>    </span><span>end</span></div></div><div><div><div>7</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>ngx.re</code> 的 <code>"jo"</code> 参数表示开启 PCRE JIT 并缓存编译后的正则，热点路径上性能差别很明显。</p></section><section><h3>3. 回源 AWS S3 的 V4 签名鉴权<a href="#3-回源-aws-s3-的-v4-签名鉴权"><span>#</span></a></h3><p>当时有客户的源站是私有的 AWS S3 桶，CDN 回源时必须按 AWS Signature Version 4 签名，否则 S3 返回 403。这个功能做成了一个 Lua 插件，region、AccessKey、SecretKey、回源域名都按域名动态配置。</p><p>V4 签名的计算分四步：</p><ol>
<li><strong>规范请求（Canonical Request）</strong>：把方法、URI、查询参数、参与签名的请求头按固定格式拼起来；</li>
<li><strong>待签字符串（String to Sign）</strong>：算法名 + 时间 + 凭证范围（日期/region/s3/aws4_request）+ 规范请求的 SHA256；</li>
<li><strong>派生签名密钥</strong>：用 <code>"AWS4" + SecretKey</code> 对日期、region、服务名、<code>aws4_request</code> 逐层做 HMAC-SHA256；</li>
<li><strong>签名</strong>：用派生密钥对待签字符串做 HMAC-SHA256，结果写进 <code>Authorization</code> 头。</li>
</ol><p>简化后的 Lua 实现（HMAC 用的是 <code>lua-resty-hmac</code> 库，回源只用 GET，所以 payload 用 <code>UNSIGNED-PAYLOAD</code>）：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>local</span><span> </span><span>resty_sha256</span><span> = </span><span>require</span><span> </span><span>"resty.sha256"</span></div></div><div><div><div>2</div></div><div><span>local</span><span> </span><span>str</span><span>  = </span><span>require</span><span> </span><span>"resty.string"</span></div></div><div><div><div>3</div></div><div><span>local</span><span> </span><span>hmac</span><span> = </span><span>require</span><span> </span><span>"resty.hmac"</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>local</span><span> </span><span>function</span><span> </span><span>sha256_hex</span><span><span>(</span><span>s</span><span>)</span></span></div></div><div><div><div>6</div></div><div><span>    </span><span>local</span><span> </span><span>h</span><span> = </span><span>resty_sha256</span><span>:</span><span>new</span><span>()</span></div></div><div><div><div>7</div></div><div><span>    </span><span>h</span><span>:</span><span>update</span><span>(</span><span>s</span><span>)</span></div></div><div><div><div>8</div></div><div><span>    </span><span>return</span><span> </span><span>str</span><span>.</span><span>to_hex</span><span>(</span><span>h</span><span>:</span><span>final</span><span>())</span></div></div><div><div><div>9</div></div><div><span>end</span></div></div><div><div><div>10</div></div><div>
</div></div><div><div><div>11</div></div><div><span>local</span><span> </span><span>function</span><span> </span><span>hmac_sha256</span><span><span>(</span><span>key</span><span>, </span><span>msg</span><span>)</span></span></div></div><div><div><div>12</div></div><div><span>    </span><span>return</span><span> </span><span>hmac</span><span>:</span><span>new</span><span>(</span><span>key</span><span>, </span><span>hmac</span><span>.ALGOS.SHA256):</span><span>final</span><span>(</span><span>msg</span><span>)  </span><span>-- 返回二进制</span></div></div><div><div><div>13</div></div><div><span>end</span></div></div><div><div><div>14</div></div><div>
</div></div><div><div><div>15</div></div><div><span>local</span><span> </span><span>ak</span><span>, </span><span>sk</span><span>   = </span><span>ngx</span><span>.var.s3_ak, </span><span>ngx</span><span>.var.s3_sk</span></div></div><div><div><div>16</div></div><div><span>local</span><span> </span><span>region</span><span>   = </span><span>ngx</span><span>.var.s3_region</span></div></div><div><div><div>17</div></div><div><span>local</span><span> </span><span>host</span><span>     = </span><span>ngx</span><span>.var.s3_host       </span><span>-- bucket.s3.&lt;region&gt;.amazonaws.com</span></div></div><div><div><div>18</div></div><div><span>local</span><span> </span><span>amz_date</span><span> = </span><span>os.date</span><span>(</span><span>"!%Y%m%dT%H%M%SZ"</span><span>, </span><span>ngx</span><span>.</span><span>time</span><span>())</span></div></div><div><div><div>19</div></div><div><span>local</span><span> </span><span>date</span><span>     = </span><span>amz_date</span><span>:</span><span>sub</span><span>(</span><span>1</span><span>, </span><span>8</span><span>)</span></div></div><div><div><div>20</div></div><div><span>local</span><span> </span><span>payload</span><span>  = </span><span>"UNSIGNED-PAYLOAD"</span></div></div><div><div><div>21</div></div><div>
</div></div><div><div><div>22</div></div><div><span>-- 1. 规范请求（URI 需按 AWS 规则逐段编码，查询参数需排序，这里省略）</span></div></div><div><div><div>23</div></div><div><span>local</span><span> </span><span>signed_headers</span><span> = </span><span>"host;x-amz-content-sha256;x-amz-date"</span></div></div><div><div><div>24</div></div><div><span>local</span><span> </span><span>canonical</span><span> = </span><span>table.concat</span><span>({</span></div></div><div><div><div>25</div></div><div><span>    </span><span>"GET"</span><span>,</span></div></div><div><div><div>26</div></div><div><span>    </span><span>ngx</span><span>.var.uri,</span></div></div><div><div><div>27</div></div><div><span>    </span><span>ngx</span><span><span>.var.args </span><span>or</span><span> </span></span><span>""</span><span>,</span></div></div><div><div><div>28</div></div><div><span>    </span><span>"host:" </span><span>.. </span><span>host</span><span> .. </span><span>"</span><span>\n</span><span>x-amz-content-sha256:" </span><span>.. </span><span>payload</span><span> .. </span><span>"</span><span>\n</span><span>x-amz-date:" </span><span>.. </span><span>amz_date</span><span> .. </span><span>"</span><span>\n</span><span>"</span><span>,</span></div></div><div><div><div>29</div></div><div><span>    </span><span>signed_headers</span><span>,</span></div></div><div><div><div>30</div></div><div><span>    </span><span>payload</span><span>,</span></div></div><div><div><div>31</div></div><div><span>}, </span><span>"</span><span>\n</span><span>"</span><span>)</span></div></div><div><div><div>32</div></div><div>
</div></div><div><div><div>33</div></div><div><span>-- 2. 待签字符串</span></div></div><div><div><div>34</div></div><div><span>local</span><span> </span><span>scope</span><span> = </span><span>date</span><span> .. </span><span>"/" </span><span>.. </span><span>region</span><span> .. </span><span>"/s3/aws4_request"</span></div></div><div><div><div>35</div></div><div><span>local</span><span> </span><span>string_to_sign</span><span> = </span><span>"AWS4-HMAC-SHA256</span><span>\n</span><span>" </span><span>.. </span><span>amz_date</span><span> .. </span><span>"</span><span>\n</span><span>" </span><span>.. </span><span>scope</span><span> .. </span><span>"</span><span>\n</span><span>" </span><span>.. </span><span>sha256_hex</span><span>(</span><span>canonical</span><span>)</span></div></div><div><div><div>36</div></div><div>
</div></div><div><div><div>37</div></div><div><span>-- 3. 派生签名密钥</span></div></div><div><div><div>38</div></div><div><span>local</span><span> </span><span>k</span><span> = </span><span>hmac_sha256</span><span>(</span><span>"AWS4" </span><span>.. </span><span>sk</span><span>, </span><span>date</span><span>)</span></div></div><div><div><div>39</div></div><div><span>k</span><span> = </span><span>hmac_sha256</span><span>(</span><span>k</span><span>, </span><span>region</span><span>)</span></div></div><div><div><div>40</div></div><div><span>k</span><span> = </span><span>hmac_sha256</span><span>(</span><span>k</span><span>, </span><span>"s3"</span><span>)</span></div></div><div><div><div>41</div></div><div><span>k</span><span> = </span><span>hmac_sha256</span><span>(</span><span>k</span><span>, </span><span>"aws4_request"</span><span>)</span></div></div><div><div><div>42</div></div><div>
</div></div><div><div><div>43</div></div><div><span>-- 4. 签名并设置回源请求头</span></div></div><div><div><div>44</div></div><div><span>local</span><span> </span><span>signature</span><span> = </span><span>str</span><span>.</span><span>to_hex</span><span>(</span><span>hmac_sha256</span><span>(</span><span>k</span><span>, </span><span>string_to_sign</span><span>))</span></div></div><div><div><div>45</div></div><div><span>ngx</span><span>.req.</span><span>set_header</span><span>(</span><span>"x-amz-date"</span><span>, </span><span>amz_date</span><span>)</span></div></div><div><div><div>46</div></div><div><span>ngx</span><span>.req.</span><span>set_header</span><span>(</span><span>"x-amz-content-sha256"</span><span>, </span><span>payload</span><span>)</span></div></div><div><div><div>47</div></div><div><span>ngx</span><span>.req.</span><span>set_header</span><span>(</span><span>"Authorization"</span><span>, </span><span>"AWS4-HMAC-SHA256 Credential=" </span><span>.. </span><span>ak</span><span> .. </span><span>"/" </span><span>.. </span><span>scope</span></div></div><div><div><div>48</div></div><div><span><span>    </span></span><span>.. </span><span>", SignedHeaders=" </span><span>.. </span><span>signed_headers</span><span> .. </span><span>", Signature=" </span><span>.. </span><span>signature</span><span>)</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>配合的 Nginx 配置要注意两点：回源的 <code>Host</code> 必须和签名里的一致，所以要 <code>proxy_set_header Host $s3_host</code>；另外 S3 只接受和服务端时间相差 15 分钟以内的签名，节点时钟一旦漂移，就会出现间歇性 403，排查起来很隐蔽。</p></section></section>
<section><h2>六、调试 Lua<a href="#六调试-lua"><span>#</span></a></h2><p>线上排查主要靠 <code>ngx.log(ngx.ERR, ...)</code> 打日志，配合 <code>error_log</code> 的级别控制。本地开发想单步调试，可以用 <a href="https://studio.zerobrane.com/" target="_blank">ZeroBrane Studio</a>，它支持远程调试 OpenResty 里的 Lua 代码；在没有图形界面的 Linux 服务器上，可以通过 <code>ssh -X</code> 把界面转发到本机。</p></section>
<section><h2>参考<a href="#参考"><span>#</span></a></h2><ul>
<li><a href="https://github.com/openresty/lua-nginx-module" target="_blank">OpenResty 官方文档 lua-nginx-module</a></li>
<li><a href="https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html" target="_blank">AWS Signature Version 4 签名流程</a></li>
<li><a href="https://github.com/jkeys089/lua-resty-hmac" target="_blank">lua-resty-hmac</a></li>
</ul></section>]]></content>
    </entry>
    <entry>
      <id>https://tracerzzz.cn/posts/gitlab-and-github/</id>
      <title type="text">一台电脑同时使用 GitHub 和公司 GitLab</title>
      <published>2017-07-11T16:00:00.000Z</published>
      <updated>2026-10-25T08:30:00.000Z</updated>
      <author><name>tracerZzz</name></author>
      <link rel="alternate" href="https://tracerzzz.cn/posts/gitlab-and-github/"/>
      <summary type="text">个人项目用 GitHub、公司项目用 GitLab 时，如何在同一台电脑上配置两套 SSH 密钥和两套提交身份，并且按目录自动切换，不用每个仓库手动设置。</summary>
      <content type="html"><![CDATA[<p>个人项目在 GitHub，公司项目在 GitLab，两边的账号、邮箱都不一样。要解决两个问题：</p>
<ol>
<li><strong>认证</strong>：两边用不同的 SSH 密钥；</li>
<li><strong>身份</strong>：提交记录里的用户名和邮箱，个人项目用个人的，公司项目用公司的。</li>
</ol>
<section><h2>一、生成两套 SSH 密钥<a href="#一生成两套-ssh-密钥"><span>#</span></a></h2><p>现在推荐用 ed25519 算法，比 RSA 更短更安全：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>ssh-keygen</span><span> </span><span>-t</span><span> </span><span>ed25519</span><span> </span><span>-C</span><span> </span><span>"个人邮箱"</span><span> </span><span>-f</span><span> </span><span>~/.ssh/id_ed25519_github</span></div></div><div><div><div>2</div></div><div><span>ssh-keygen</span><span> </span><span>-t</span><span> </span><span>ed25519</span><span> </span><span>-C</span><span> </span><span>"公司邮箱"</span><span> </span><span>-f</span><span> </span><span>~/.ssh/id_ed25519_gitlab</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>然后把两个 <code>.pub</code> 公钥分别添加到 GitHub 和 GitLab 的个人设置里。</p></section>
<section><h2>二、用 ~/.ssh/config 按域名选择密钥<a href="#二用-sshconfig-按域名选择密钥"><span>#</span></a></h2><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span># 个人 GitHub</span></div></div><div><div><div>2</div></div><div><span>Host</span><span> </span><span>github.com</span></div></div><div><div><div>3</div></div><div><span>    </span><span>HostName</span><span> github.com</span></div></div><div><div><div>4</div></div><div><span>    </span><span>User</span><span> git</span></div></div><div><div><div>5</div></div><div><span>    </span><span>IdentityFile</span><span> ~/.ssh/id_ed25519_github</span></div></div><div><div><div>6</div></div><div><span>    </span><span>IdentitiesOnly</span><span> </span><span>yes</span></div></div><div><div><div>7</div></div><div>
</div></div><div><div><div>8</div></div><div><span># 公司 GitLab</span></div></div><div><div><div>9</div></div><div><span>Host</span><span> </span><span>gitlab.example.com</span></div></div><div><div><div>10</div></div><div><span>    </span><span>HostName</span><span> gitlab.example.com</span></div></div><div><div><div>11</div></div><div><span>    </span><span>User</span><span> git</span></div></div><div><div><div>12</div></div><div><span>    </span><span>IdentityFile</span><span> ~/.ssh/id_ed25519_gitlab</span></div></div><div><div><div>13</div></div><div><span>    </span><span>IdentitiesOnly</span><span> </span><span>yes</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>IdentitiesOnly yes</code> 很重要：没有它，SSH 会先把 ssh-agent 里所有的密钥挨个试一遍，可能用错密钥，或者尝试次数过多被服务器断开。</p><p>配好后验证：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>ssh</span><span> </span><span>-T</span><span> </span><span>git@github.com</span></div></div><div><div><div>2</div></div><div><span>ssh</span><span> </span><span>-T</span><span> </span><span>git@gitlab.example.com</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>
<section><h2>三、按目录自动切换提交身份<a href="#三按目录自动切换提交身份"><span>#</span></a></h2><p>当年的做法是在每个公司仓库里手动执行 <code>git config --local user.name/user.email</code>，很容易忘。Git 2.13 之后可以用 <code>includeIf</code>，按仓库所在目录自动加载不同的配置。</p><p><code>~/.gitconfig</code>：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>[user]</span></div></div><div><div><div>2</div></div><div><span>    </span><span>name</span><span><span> </span><span>=</span><span> 个人用户名</span></span></div></div><div><div><div>3</div></div><div><span>    </span><span>email</span><span><span> </span><span>=</span><span> 个人邮箱</span></span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span># work 目录下的仓库，自动使用公司身份</span></div></div><div><div><div>6</div></div><div><span>[includeIf "gitdir:~/work/"]</span></div></div><div><div><div>7</div></div><div><span>    </span><span>path</span><span><span> </span><span>=</span><span> ~/.gitconfig-work</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p><code>~/.gitconfig-work</code>：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>[user]</span></div></div><div><div><div>2</div></div><div><span>    </span><span>name</span><span><span> </span><span>=</span><span> 公司用户名</span></span></div></div><div><div><div>3</div></div><div><span>    </span><span>email</span><span><span> </span><span>=</span><span> 公司邮箱</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>公司的项目都放在 <code>~/work/</code> 下面，提交时就会自动用公司身份。在仓库里执行 <code>git config user.email</code> 可以确认当前生效的是哪个。</p></section>
<section><h2>四、遇到过的问题<a href="#四遇到过的问题"><span>#</span></a></h2><p>当时推送到公司 GitLab 时出现过这个报错：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>ssh_exchange_identification: read: Connection reset by peer</span></div></div><div><div><div>2</div></div><div><span>fatal: Could not read from remote repository.</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>它的意思是 SSH 连接在握手阶段就被服务端断开了，还没轮到验证密钥，所以和密钥配置无关。常见原因是服务端限制了允许 SSH 的来源 IP（比如只放行公司内网或 VPN），或者短时间内连接次数过多被拦截。当时的临时办法是改用 HTTPS 地址推送；根本的解决办法是找管理员确认 SSH 访问策略。</p></section>]]></content>
    </entry>
</feed>
