竹林里有冰的博客

竹林里有冰的博客

吃大鹅(dae)这件事,Cudy 小路由一个人干不动了

<h2>背景</h2> <p>先打个招呼:下文里的「大鹅」「大鹅蛋」「喵喵内核」,分别对应大家熟悉的那套透明代理组合。</p> <p>之前我在 <a href="/2025/02/28/cudy-tr3000-daed-install-record/">Cudy TR3000 上吃过大鹅蛋</a>。实习租房那会儿宽带只有 200Mbps,测下来代理下行能跑满,CPU 也看着还行,一度觉得这台小路由自己扛透明代理就够了。</p> <p>离职回家以后带宽粗了不少,Cudy 就开始露馅:直连能跑满的速度,一走代理就掉下来。网口挺体面,真干活还是那颗小 SoC 扛不住加密解密和规则匹配。</p> <p>内存也是坑。Cudy 只有 512MB,挂美西这种高延迟节点时,TCP 窗口会跟着 RTT 往上抬,缓冲区吃得很凶。同样的协议,亚太低延迟节点更容易把带宽打满,美西反而跑不满——最开始我还以为是节点质量问题,后来才发现小路由自己在添堵。</p> <p>主路由我也不打算动。家里还靠小米官方系统做 Mesh,米家智能家居网关也压在它身上。代理调炸了事小,灯泡插座扫地机器人一起赛博失联就很难绷。主路由继续干拨号、Wi-Fi、DHCP、Mesh、米家,代理实验别往上塞。</p> <p>最开始当然也想过 all in one,一个小盒子插上电就完事,多优雅。可惜家用网络里 all in one 离 all in boom 往往只有一次手贱更新的距离。于是现在改成半拆:主路由下面同时挂 Cudy 和 N100。Cudy 跑大鹅蛋当透明代理入口,先用 geosite / geoip 把 CN 流量直连出主路由;剩下的交给 N100 上的喵喵,DNS 也走它的 fake-ip。</p> <h2>网络拓扑</h2> <p>下面 IP 是示意用的,别和家里真实网段对号入座。</p> <p>物理连接:</p> <pre><code class="language-mermaid">flowchart TB Internet((互联网)) MainRouter["主路由&#x3C;br/>&#x3C;b style='color:#b45309'>192.168.66.1&#x3C;/b>&#x3C;br/>小米 Mesh / 米家网关"] Cudy["Cudy TR3000&#x3C;br/>&#x3C;b style='color:#b45309'>192.168.66.2&#x3C;/b>&#x3C;br/>大鹅蛋 / LAN &#x3C;b style='color:#b45309'>192.168.67.1&#x3C;/b>"] N100["N100&#x3C;br/>&#x3C;b style='color:#b45309'>192.168.66.3&#x3C;/b>&#x3C;br/>喵喵"] Clients["代理设备&#x3C;br/>&#x3C;b style='color:#b45309'>192.168.67.0/24&#x3C;/b>"] DirectClients["普通设备&#x3C;br/>&#x3C;b style='color:#b45309'>192.168.66.0/24&#x3C;/b>"] Internet --- MainRouter MainRouter --- Cudy MainRouter --- N100 MainRouter --- DirectClients Cudy --- Clients </code></pre> <p>流量走向:</p> <pre><code class="language-mermaid">flowchart LR Clients["代理设备&#x3C;br/>&#x3C;b style='color:#b45309'>192.168.67.x&#x3C;/b>"] --> Cudy["Cudy&#x3C;br/>&#x3C;b style='color:#b45309'>192.168.66.2&#x3C;/b>&#x3C;br/>大鹅蛋"] Cudy -->|CN 直连| MainRouter["主路由&#x3C;br/>&#x3C;b style='color:#b45309'>192.168.66.1&#x3C;/b>"] Cudy -->|socks5 :7890| N100["N100&#x3C;br/>&#x3C;b style='color:#b45309'>192.168.66.3&#x3C;/b>&#x3C;br/>喵喵"] Cudy -->|fake-ip DNS :1053| N100 N100 --> MainRouter MainRouter --> Internet((互联网)) </code></pre> <p>CN 在大鹅蛋这一层就直连出主路由。其余该走代理的流量,连同 fake-ip DNS 查询,一起交给 N100;其中 fake-ip 对应的 <code>198.18.0.0/16</code> 也必须走 socks5,别被 CN / private 直连规则误伤。</p> <h2>喵喵侧</h2> <p>N100 上开两个东西给 Cudy 用:<code>mixed-port</code> 暴露出来的 socks,以及 fake-ip DNS。为什么优先 fake-ip 而不是 real-ip,Sukka 这篇 <a href="https://blog.skk.moe/post/lets-talk-about-dns-cdn-fake-ip/">《谈谈 DNS 泄漏、CDN 访问优化与 Fake IP》</a> 讲得很清楚,这里不展开;简单说就是响应速度更快、也少踩 CDN 调度的坑。简化版大概长这样:</p> <pre><code class="language-yaml">mixed-port: 7890 allow-lan: true mode: rule log-level: info unified-delay: true dns: enable: true enhanced-mode: fake-ip listen: 0.0.0.0:1053 default-nameserver: - 223.5.5.5 nameserver: - https://doh.pub/dns-query - https://dns.alidns.com/dns-query direct-nameserver: - 192.168.66.1 - 119.29.29.29 proxy-providers: sub: type: http url: "https://example.com/sub/xxxxxxxx" interval: 21600 path: ./providers/sub.yaml health-check: enable: true interval: 600 url: http://www.gstatic.com/generate_204 rule-providers: lan: type: http behavior: classical format: text url: https://cdn.jsdelivr.net/gh/ACL4SSR/ACL4SSR@master/Clash/LocalAreaNetwork.list path: ./rules/lan.yaml interval: 86400 proxylite: type: http behavior: classical format: text url: https://cdn.jsdelivr.net/gh/ACL4SSR/ACL4SSR@master/Clash/ProxyLite.list path: ./rules/proxylite.yaml interval: 86400 chinadomain: type: http behavior: classical format: text url: https://cdn.jsdelivr.net/gh/ACL4SSR/ACL4SSR@master/Clash/ChinaDomain.list path: ./rules/chinadomain.yaml interval: 86400 proxy-groups: - name: 🚀 节点选择 type: select proxies: - ⚡ 最低延迟 - DIRECT use: - sub - name: ⚡ 最低延迟 type: url-test url: http://www.gstatic.com/generate_204 interval: 180 tolerance: 50 use: [sub] - name: 🎯 全球直连 type: select proxies: - DIRECT - name: 🐟 漏网之鱼 type: select proxies: - 🚀 节点选择 - 🎯 全球直连 rules: - RULE-SET,lan,🎯 全球直连 - RULE-SET,proxylite,🚀 节点选择 - RULE-SET,chinadomain,🎯 全球直连 - GEOIP,LAN,🎯 全球直连 - GEOIP,CN,🎯 全球直连 - MATCH,🐟 漏网之鱼 </code></pre> <p><code>allow-lan</code> 别关,DNS 监听 <code>0.0.0.0:1053</code>。直连域名的解析可以丢给主路由 DNS(示意里是 <code>192.168.66.1</code>),也可以留着不设置,或者是用阿里腾讯等常见的公共 DNS,不碍事。规则集和代理组实际比上面多,这里只留骨架。</p> <h2>大鹅蛋侧</h2> <p>Cudy 上用的是大鹅蛋图形界面。socks 指到 <code>192.168.66.3:7890</code>,DNS 指到 <code>192.168.66.3:1053</code>,N100 地址最好固定。</p> <p><img src="https://static.031130.xyz/uploads/2026/07/17/9150160eb44e1.webp" alt="Global 配置,需要把 br-lan 加入绑定的 LAN 接口以代理 Cudy 下的设备"></p> <p>DNS 规则示意:CN 走普通解析,其他走喵喵拿 fake-ip。</p> <pre><code class="language-ini">upstream { mainrouter: 'udp://192.168.66.1:53' mihomo: 'udp://192.168.66.3:1053' } routing { request { qname(geosite:cn) -> mainrouter fallback: mihomo } } </code></pre> <p>routing 规则如下,关键是把 fake-ip 网段强制送给喵喵:</p> <pre><code class="language-ini">routing { pname(NetworkManager, systemd-resolved, dnsmasq) -> must_direct dip(198.18.0.1/16) -> proxy dip(192.168.66.1/24) -> direct dip(geoip:private) -> direct dip(geoip:cn) -> direct domain(geosite:cn) -> direct fallback: proxy } </code></pre> <p><code>dip(198.18.0.1/16) -> proxy</code> 要写在 <code>domain(geosite:cn) -> direct</code> 和 <code>dip(geoip:private) -> direct</code> 前面。喵喵回的 fake-ip 都落在这个网段里;客户端随后连的也是这些假 IP。<strong>如果不强制送过去,大鹅可能把流量判成直连</strong>,结果去连一个根本不存在的 <code>198.18.x.x</code>。</p> <p>至于要不要上这套半拆方案,看家宽和节点吧。公寓里那 200Mbps,Cudy 一个人吃鹅完全没问题;带宽一宽、再挂上美西,它就该找个打工人了。主路由继续当米家,Cudy 看门分流,N100 扛重活——至少现在,吃鹅这件事不用再让一台迷你路由单刷。</p> <p>最后和大家道个歉:为了让博客在特定区域能多活一段时间,文里用了不少暗语,如果因此读起来有点跳戏,还请见谅。</p> <h2>参见</h2> <ul> <li><a href="/2025/02/28/cudy-tr3000-daed-install-record/">Cudy TR3000 吃鹅记</a></li> <li><a href="https://blog.skk.moe/post/lets-talk-about-dns-cdn-fake-ip/">是什么,为什么,怎么做 —— 谈谈 DNS 泄漏、CDN 访问优化与 Fake IP | Sukka's Blog</a></li> <li><a href="https://github.com/daeuniverse/dae">daeuniverse/dae: eBPF-based Linux high-performance transparent proxy solution.</a></li> </ul>

2026/7/17
阅读更多

Nuxt SSG 博客的尾斜杠到底怎么加?

<p>本站是用 Nuxt v4 + Nuxt Content v3 + i18n 搭出来的纯 SSG 博客。开站时随手定了一个看似无关紧要的策略——<strong>所有页面 URL 以 <code>/</code> 结尾</strong>。</p> <p>听起来一行配置就该完事的事,做下来才发现 Nuxt 在尾斜杠这件事上<strong>至今没有一个统一的官方开关</strong>(<a href="https://github.com/nuxt/nuxt/issues/15462">nuxt/nuxt#15462</a> 这个 issue 从 2022 年挂到现在),整套策略最后是靠<strong>六个不同层面</strong>拼出来的。这篇就把站点里所有跟 trailing slash 相关的配置完整盘一遍,留作给自己和后人的备忘。</p> <h2>为什么要尾斜杠</h2> <p>简单提一句动机:</p> <ul> <li>同一篇文章 <code>/2026/05/28/foo</code> 和 <code>/2026/05/28/foo/</code> 在搜索引擎眼里<strong>理论上是两个 URL</strong>,要么你给一个 canonical,要么干脆只允许一种形态;</li> <li>SSG 产物里"目录形态"更自然——<code>about/index.html</code> 比 <code>about.html</code> 更便于嵌套子页面、也更符合直觉;</li> <li>风格上,我个人更喜欢看 URL 末尾那个斜杠。</li> <li>在我的博客重构前使用的 hexo 框架就是这样的,我希望保留原有 URL 不变。</li> </ul> <p>确定了"全部带斜杠"这个目标,下面要做的事就是<strong>让站点的每一个发出 URL 的地方、每一个接收 URL 的地方、每一个用 URL 做 key 的地方都遵守这条约定</strong>。</p> <h2>Layer 1:SEO 层</h2> <p>最先想到的是 SEO,所以 <code>@nuxtjs/seo</code> 的配置里:</p> <pre><code class="language-ts">// nuxt.config.ts site: { trailingSlash: true, } </code></pre> <p>这个开关<strong>只影响</strong> canonical link、sitemap.xml、robots.txt、OpenGraph URL 等 SEO 模块生成的 URL。它不会改你页面里实际渲染出来的 <code>&#x3C;a href></code>,也不会拦截入站请求。但既然它是"对外发布我自己的 URL 形态",配上就对了。</p> <h2>Layer 2:出站链接</h2> <p>第二层是页面里 <code>&#x3C;NuxtLink></code> 渲染出来的 <code>href</code>。Nuxt 4 提供了 experimental 配置:</p> <pre><code class="language-ts">// nuxt.config.ts experimental: { defaults: { nuxtLink: { trailingSlash: 'append' } } } </code></pre> <p>打开以后,全站任何 <code>&#x3C;NuxtLink to="/about"></code> 渲染出来都是 <code>href="/about/"</code>,<strong>不管你 <code>to</code> 写没写斜杠</strong>。</p> <p>需要注意的边界:</p> <ul> <li> <p>这个配置<strong>不影响</strong> <code>router.push('/about')</code> 这种代码侧的导航,所以代码里手动 push 时还得自己拼。本站基本走 <code>localePath()</code> + <code>NuxtLinkLocale</code>,绕过了这个雷区。</p> </li> <li> <p><code>localePath('/tags')</code> 拼出来的 <code>/tags</code>,外面再手动 <code>+ '/' + tagName</code> 的话,最后一段不带 <code>/</code>,<strong>但</strong> NuxtLink 的 <code>'append'</code> 会兜底再补一次。比如:</p> <pre><code class="language-vue">&#x3C;NuxtLink :to="`${localePath('/tags')}/${encodeURIComponent(tag)}`"> </code></pre> <p>实际渲染出来的 href 是 <code>/tags/Foo/</code>。</p> </li> </ul> <h2>Layer 3:硬编码的链接</h2> <p>虽然有了 <code>'append'</code> 兜底,但项目里还是把所有硬编码的链接都直接写成带斜杠的形式,作为第二道防线:</p> <pre><code class="language-vue">&#x3C;!-- FooterContent.vue --> &#x3C;NuxtLinkLocale to="/donate/" aria-label="Donate"> </code></pre> <pre><code class="language-ts">// Pagination.vue function getPageUrl(page: number) { if (page &#x3C; 1 || page > props.totalPages) return '#' return page === 1 ? `${props.urlPrefix}/` : `${props.urlPrefix}/page/${page}/` } </code></pre> <p>养成这个习惯有个好处:将来如果 Nuxt 把 <code>experimental.defaults.nuxtLink.trailingSlash</code> 又改名了或者拿掉了(experimental API 嘛,懂的都懂),站点也不会因为这个一夜暴毙。</p> <h2>Layer 4:Prerender 产物落地形态</h2> <p>SSG 阶段是 Nitro 在干活。它有个<strong>默认开启</strong>的配置叫 <code>prerender.autoSubfolderIndex</code>——会把 prerender 出来的每个页面落到 <code>&#x3C;path>/index.html</code>,而不是 <code>&#x3C;path>.html</code>。</p> <pre><code>.output/public/ ├── about/ │ ├── index.html │ └── _payload.json ├── 2026/ │ └── 05/ │ └── 28/ │ └── nuxt-ssg-trailing-slash-hydration-trap/ │ ├── index.html │ └── _payload.json └── ... </code></pre> <p>这一步意味着,无论是 Vercel 这种 serverless 平台,还是 Nginx / Caddy,请求 <code>/about</code> 和 <code>/about/</code> 两种形态,<strong>静态文件服务器都能 fallback 到同一份 <code>about/index.html</code></strong>——所以"用户输错斜杠也能开页"这件事根本不需要应用层兜底。</p> <blockquote> <p>顺便:本站还显式列了几条 prerender route:</p> <pre><code class="language-ts">nitro: { prerender: { routes: [ '/rss.xml', '/en/rss.xml', '/search/sections.json', '/tags/Vue.js', // 带 . 的标签页,crawler 不会自动跟进 ...Object.keys(blogConfig.redirects) ] } } </code></pre> <p>这些是 crawler 抓不到、必须显式喂的,跟尾斜杠没直接关系,但放在这里作为完整的 nitro 配置一并列出。</p> </blockquote> <h2>Layer 5:入站 URL 规范化(纯前端)</h2> <p>到这里 SEO、出站链接、产物落地都齐了,但<strong>有一类场景还没覆盖</strong>——用户手敲一个没斜杠的 URL(或者外部跳转过来),地址栏里挂着 <code>/about</code>,需要不需要把它改写成 <code>/about/</code>?</p> <p>经典做法是 HTTP 301。但本站是<strong>双平台部署(Vercel + Caddy)</strong>,301 就得两份规则,能避免就避免。而且 Vercel 是把 SSG 产物放在 CDN 上的,301 写在 <code>vercel.json</code> 里也算半个绑定方案,不够纯粹。</p> <p>所以这一层走<strong>纯前端</strong>:一个全局 client middleware。</p> <pre><code class="language-ts">// app/middleware/trailing-slash.global.ts export default defineNuxtRouteMiddleware((to) => { if (import.meta.server) return if (to.path === '/' || to.path.endsWith('/')) return // 跳过 favicon.ico、rss.xml 这类带后缀的资源路径 const lastSegment = to.path.slice(to.path.lastIndexOf('/') + 1) if (lastSegment.includes('.')) return return navigateTo( { path: to.path + '/', query: to.query, hash: to.hash }, { replace: true } ) }) </code></pre> <p>几个细节:</p> <ul> <li><strong><code>import.meta.server</code> 直接 return。</strong> SSG prerender 阶段 Nitro 自己已经归一化了;如果在 server 端再 <code>navigateTo</code>,可能在产物里写出非预期的 30x 跳转。</li> <li><strong><code>replace: true</code></strong> 让浏览器<strong>替换</strong>当前 history 条目,不会留一条"刚刚那个没斜杠的版本"的返回栈。</li> <li><strong>排除带 <code>.</code> 的路径</strong>,避免误把静态资源也加上斜杠。</li> </ul> <p>这层做完后,全链路 0 个 HTTP 301,配置上也不绑任何一家部署平台。</p> <h2>Layer 6:<code>useAsyncData</code> 的 key(隐藏的雷区)</h2> <p>前五层做完,URL 的形态已经全部规范化,但还有一层非常隐蔽的地方需要照顾——<strong><code>useAsyncData</code> 的 key</strong>。</p> <p>很容易写出这种代码:</p> <pre><code class="language-ts">const { data } = useAsyncData( `randomIndex${route.path}`, // ← 雷 async () => ... ) </code></pre> <p>问题是 <code>route.path</code> 在 SSR / client / prerender / SPA 导航这四种上下文里<strong>不一定一致</strong>。一旦 key 在 SSR 时算出 <code>randomIndex/about</code>、客户端水合时算出 <code>randomIndex/about/</code>,payload 命中失败,整个 <code>useAsyncData</code> 在客户端会<strong>重跑一遍</strong>,对应组件直接退化成 CSR。</p> <p>本站的处理是:<strong>所有 <code>useAsyncData</code> 的 key 都不沾 <code>route.path</code></strong>,要带路由信息就用 <code>route.name</code> + <code>route.params</code>:</p> <pre><code class="language-ts">const route = useRoute() const routeKey = `${String(route.name ?? 'unknown')}-${JSON.stringify(route.params)}` const { data: randomIndex } = useAsyncData( `randomIndex-${routeKey}`, async () => Math.floor(Math.random() * appConfig.appearance.backgrounds.length) ) </code></pre> <p><code>route.name</code> 是 vue-router 内部的路由名(i18n 自动生成的形如 <code>about___zh</code>),<code>route.params</code> 是动态段,<strong>两者在任何上下文都一致</strong>。最终构建出来的 <code>_payload.json</code> key 形如 <code>randomIndex-about___zh-{}</code>,跟尾斜杠完全脱钩。</p> <h2>整体回顾</h2> <p>整个站点的尾斜杠策略可以一句话总结:</p> <blockquote> <p>Prerender 时让 Nitro 落到 <code>xxx/index.html</code>,SEO 由 <code>site.trailingSlash</code> 负责对外发布形态,出站链接由 <code>nuxtLink.trailingSlash: 'append'</code> 自动补斜杠(+ 硬编码做第二道防线),入站直链由全局 client middleware 兜底,<code>useAsyncData</code> 的 key 一律不依赖 <code>route.path</code> —— <strong>全链路 0 个 HTTP 301</strong>。</p> </blockquote> <p>对照表:</p> <table> <thead> <tr> <th>层</th> <th>配置/代码</th> <th>解决什么</th> </tr> </thead> <tbody> <tr> <td>SEO</td> <td><code>site.trailingSlash: true</code></td> <td>canonical / sitemap / OG URL</td> </tr> <tr> <td>出站链接</td> <td><code>nuxtLink.trailingSlash: 'append'</code></td> <td><code>&#x3C;NuxtLink></code> 渲染出来的 href</td> </tr> <tr> <td>硬编码</td> <td><code>to="/donate/"</code> 这种</td> <td>兜底 + 风格统一</td> </tr> <tr> <td>产物落地</td> <td><code>nitro.prerender.autoSubfolderIndex</code>(默认)</td> <td>让 <code>/about</code> 和 <code>/about/</code> 命中同一文件</td> </tr> <tr> <td>入站 URL</td> <td>global client middleware</td> <td>用户输错 / 外链跳转的地址栏规范化</td> </tr> <tr> <td>数据层</td> <td><code>useAsyncData</code> 的 key 用 <code>route.name + params</code></td> <td>避免两端 key 错位导致水合崩盘</td> </tr> </tbody> </table> <h2>小插曲:这套方案是怎么来的</h2> <p>说起来这套配置并不是我开站时一次性想清楚的,最后两层(client middleware 和 <code>useAsyncData</code> 的 key)其实是前几天 debug 一个怪现象时被迫补上去的。</p> <p>那天我打开自己博客的 <a href="/about/">/about/</a> 页,注意到一个怪事——背景图每次进来都"啪"地换一张。F12 一看,控制台挂着 Vue 的 <code>Hydration completed but contains mismatches.</code>。</p> <p><img src="https://static.031130.xyz/uploads/2026/05/28/e1dab1bdd0721.webp" alt="控制台报错"></p> <p>明明 SSG 出来的纯静态产物,HTML 里 <code>&#x3C;div id="__nuxt"></code> 都齐齐整整,凭什么客户端不认账?</p> <p><code>useAsyncData</code> 是怎么命中 payload 的——key 一致就读 payload,不一致就重跑 fetch。那只能是 key 不一致。把构建产物的 <code>_payload.json</code> 抠出来看:</p> <pre><code class="language-json">{"randomIndex/about": ...} </code></pre> <p>key 是 <code>randomIndex/about</code>,<strong>没有尾斜杠</strong>。可这个文件本身在 <code>.output/public/about/_payload.json</code>,浏览器访问的 URL 是 <code>/about/</code>,客户端 <code>route.path</code> 拼出来的 key 是 <code>randomIndex/about/</code>——<strong>多了一个斜杠</strong>。</p> <p><code>Math.random()</code> 在客户端重跑,DOM 与服务端渲染对不上,水合崩盘,整页 re-render。</p> <p>罪魁祸首就是 <code>route.path</code> 在 SSR / client 两端因为 Nitro prerender 的归一化时机而不一致。修起来不难——<code>useAsyncData</code> 的 key 改用 <code>route.name + params</code>,跟路径解耦就完了。</p> <p>修完才想起来,地址栏里那个没斜杠的 URL 还在挂着,于是又顺手把 client middleware 也加上,把"用户输错斜杠"这条路径也一并接住。</p> <p>完了发现这是个值得正经写一篇下来留底的事——所以才有了这篇文章。</p>

2026/5/28
阅读更多

小米 Xiaomi Book Pro 14 (Ultra X7) Linux 兼容性实测

<h2>深夜的“冲动”消费</h2> <p>前几天深夜 0 点,躺在床上百无聊赖刷着京东,居然奇迹般地逮到了这台常年断货的 Xiaomi Book Pro 14 2026 顶配版的现货!这突然弹出来的购买按钮,简直像是在跟我招手。虽然脑子里天人交战了一番,最后我的手还是非常诚实地按下了付款键。</p> <p>虽说错过了首发折扣,原价 10499 让人隐隐有些肉疼,但好在还能叠一波本地的国补,实付 8999 拿下的价格也算是“真香”了。仔细想想一点也不亏,毕竟也是时候让我那台任劳任怨、被我折腾了四年的老伙计 ThinkPad T14 Gen2i 顺理成章地退居二线享清福啦。</p> <h2>Linux 兼容性:一场“赌博”与测试计划</h2> <p>这台本子在近一个月的自媒体测评中表现亮眼,我也不过多介绍了,总之其搭载的 Panther Lake 提供的性能提升和续航表现都非常不错,再加上其 1.07kg 的轻巧重量,让我非常心动。剩下唯一的不确定因素就是它的 Linux 兼容性了。由于是新机,在加上其搭载的 Panther Lake 处理器产能极低,所以没指望有 Linux 用户能第一时间上手测试。看了看是 Intel 的平台,网卡也是 Intel 的,所以我决定赌一把。到货后不直接激活,通过 <code>oobe\bypassnro</code> 来跳过微软账户的绑定,直接进入系统桌面后关闭快速启动,便掏出 LiveCD 来测试。</p> <p><img src="https://static.031130.xyz/uploads/2026/04/30/effcba4d1ab8d.webp" alt="(桌上很乱,图片经过 AI 处理)"></p> <p>我是这样打算的:如果能进入 LiveCD,经过简单的测试后发现没有什么大问题的话,就确认收货、联网激活;如果确实是 Linux 兼容性有问题且短时间内无法解决的话,就只能退货了,因为我确实是个重度 Linux 用户。</p> <h2>测试项目与 LiveCD 结果</h2> <p>测试项如下:</p> <ul> <li>正确进入桌面环境</li> <li>WIFI</li> <li>蓝牙</li> <li>声卡</li> <li>摄像头</li> <li>内置键盘</li> <li>触控板</li> </ul> <blockquote> <p><em>注:需要提前向大家说明的是,对于 <strong>指纹识别</strong> 以及 <strong>电源管理(包括系统的睡眠、休眠等状态切换)</strong> 相关的特性,在本次实测中我并未作专门验证,主要考虑到我自己日常在 Linux 下基本不会用到指纹等功能,所以在这方面没有过多纠结。</em></p> </blockquote> <p>好在核心基础配置的测试结果都没辜负我的期望,我简单讲讲几个 LiveCD 的测试结果</p> <table> <thead> <tr> <th>发行版</th> <th>内核版本</th> <th>结果</th> </tr> </thead> <tbody> <tr> <td>Ubuntu 26.04</td> <td>7.0</td> <td>键盘不识别,内置屏幕偶有花屏</td> </tr> <tr> <td>Fedora 44</td> <td>6.18</td> <td>键盘不识别,内置屏幕偶有花屏,无声音</td> </tr> <tr> <td>CachyOS 260426</td> <td>6.18</td> <td>键盘不识别,内置屏幕偶有花屏,无声音</td> </tr> </tbody> </table> <h2>解决困境:万能的内核参数</h2> <p>从上面的测试结果可以看出,在 7.0 以上的内核版本修复了声音的问题,而我在一番搜索后找到了<a href="https://meixg.cn/2026/03/25/xiaomi-book-pro-14-omarchy/">这样一篇博客</a>能够解决键盘不识别和内置屏幕偶有花屏的问题。</p> <p>重新进入 LiveCD,在 GRUB 界面按 e 进入编辑模式,在 linux 行的末尾添加 <code>i8042.nopnp=1 i8042.dumbkbd=1 xe.force_probe=b081 i915.force_probe=!b081 xe.enable_psr=0</code> 参数后按 F10 启动,进入系统后键盘就能正常使用了,内置屏幕偶有花屏的问题也不再出现了。于是我连上 WIFI 放了几个小时的 YouTube 8K 视频,没出现什么明显的问题,蓝牙、声卡、摄像头经测试也都正常工作。</p> <h2>系统迁移与最终体验</h2> <p>这样一来,这台 Xiaomi Book Pro 14 2026 的 Linux 兼容性可以说是超出预期的,在正式安装系统时只需要添加几段内核参数就能解决之前测试中遇到的几个问题。</p> <p>于是便是迁移过程,这部分内容就不展开了,简单来说就是新建 Linux 根目录分区、通过 rsync 将老的系统迁移过来、重建 fstab 分区表和 GRUB 引导、最后安装好系统后再添加之前提到的内核参数,重启后就能正常使用了。</p> <p>rsync 的命令大概是这样的:</p> <pre><code class="language-bash">rsync -axHAWXS --numeric-ids &#x3C;source> &#x3C;destination> </code></pre> <p><img src="https://static.031130.xyz/uploads/2026/04/30/99f106c17dd0c.webp" alt=""></p> <h2>总结</h2> <p>总的来说,这台 Xiaomi Book Pro 14 2026 在 Linux 上的兼容性表现还是非常不错的,虽然在初始测试中遇到了一些问题,但通过添加内核参数后这些问题都得到了有效解决。我目前已经联网激活自带的 Windows 系统并确认收货,同时也成功迁移了 Linux 系统,整体体验非常满意。</p> <h2>参见</h2> <ul> <li><a href="https://meixg.cn/2026/03/25/xiaomi-book-pro-14-omarchy/">在 Xiaomi Book Pro 14 (2026) 上运行 Omarchy (Arch + Hyprland) </a></li> </ul>

2026/4/30
阅读更多

国内(大陆)版小米 FCM 熄屏断连:Rootless 环境下的尝试与可能的解决方案

<p>在去年的 11 月,我入手了一台新的国行版的小米 15 作为我的主力机。其实之前手上的 Redmi K70 Ultra 与这台小米 15 是同一年发布的产品,性能上也不相上下,但我在 Gap Year 期间在全国多地旅行,意识到没能将自己所见的夜景拍下是一件挺可惜的事,于是就想换一台拍照更好的手机。新发布的小米 17 在价格上正处于高位,且相对于 15 来说并没有太大的提升,所以我就直接入了小米 15。</p> <p>入手以后,我按照自己的使用习惯打开了设置中的「谷歌基础服务」,安装好「Google Play」后便正常使用。但就在这几个月的使用过程中,我发现我的 fcm 推送——无论是 outlook 邮件通知,还是某外观形似纸飞机的即时通讯软件,他们的 fcm 推送好像自始至终都没有正常 work 过。它们的通知偶尔会在我解锁手机后突然冒出来,但更多的时候它们就是不见了。一直要到我手动打开对应的 app,才会收到之前积压的通知。</p> <p>最近收到了海外院校的 offer,准备在 9 月份去读<span class="heimu">水硕</span>了,所以就想在出国之前把这个问题解决掉。毕竟在国内生活的时候,大部分软件能通过 mipush 给我推送消息,这些依赖 fcm 推送的 app 并不是我使用频率最高的 app,我每天睡醒手动打开一下就能收到通知了,即使回复消息的及时性不太好也无伤大雅,真有急事的话我的家人朋友们也知道哪些方式能更快的 reach 我。但到了国外,fcm 推送就变得非常重要了,所以要尽快处理掉这个问题,要不然我就得考虑换手机了。</p> <h2>测试环境</h2> <ul> <li>小米 15 国内版,16G + 512G,HyperOS 3.0.7.0.WOCCNXM,是截至本文撰写时最新的版本</li> <li>已开启「谷歌基础服务」,已安装「Google Play」</li> <li>连接 WIFI,且 WIFI 分流后的数据出口 IP 在🇺🇸洛杉矶</li> <li>未关闭 MIUI 优化</li> </ul> <h2>先把 FCM 跑起来</h2> <p>我需要先解决 FCM 在亮屏状态下也无法接收消息的问题。在「电话」界面的拨号盘输入 <code>*#*#426#*#*</code> 可以进入 FCM Diagnostics 界面,里面有一些关于 FCM 连接状态的日志输出。</p> <p><img src="https://static.031130.xyz/uploads/2026/02/20/6202d116db231.webp" alt="拨号盘输入"> <img src="https://static.031130.xyz/uploads/2026/02/20/432922f339849.webp" alt="FCM Diagnostics 界面"></p> <p>在这个界面中,我发现 FCM 的连接状态其实是正常的,日志里也没有什么明显的错误信息。于是我就开始怀疑是不是 HyperOS 的某些省电机制在后台干扰了 FCM 的正常工作。通过在小红书的一番搜索,我发现在设置界面打开需要 FCM 推送的 App 的「自启动」权限,并且把电池优化设置为「不优化」,似乎能解决这个问题。</p> <p><img src="https://static.031130.xyz/uploads/2026/02/20/8c20be931764b.webp" alt="App 设置界面"></p> <p>通过 IM 软件(用一个号给另一个号发消息)测试,我发现在亮屏状态下,FCM 可以在 App 后台被关闭的情况下正常接收消息了。</p> <h2>熄屏一分钟后 FCM 断连的问题</h2> <p>然而,当我把手机熄屏后静置一分钟后,FCM 就完全不工作了。无论是邮件通知还是 IM 消息,都无法通过 FCM 推送到手机上。只有在我点亮屏幕的一瞬间,之前积压的通知才会突然冒出来。再通过 FCM Diagnostics 界面查看,FCM 的连接状态要么是 disconnected,要么是刚刚 connected 几秒。这说明在锁屏状态下,FCM 的连接会被系统断开,导致无法接收消息。</p> <p>PS: 我发现在充电状态下,即使手机锁屏,FCM 也能保持连接并正常接收消息。这就更加印证了是 HyperOS 的省电机制在干扰 FCM 的正常工作了。</p> <h2>可能的解决方案</h2> <p>在小红书和小绿书(酷安)一番搜索后,我找到了前人的一些尝试和解决方案:</p> <h3>1. 关闭 MIUI 优化</h3> <p>这是我最不喜欢的方案,因为 MIUI 优化确实是我喜欢 HyperOS (MIUI) 的一个重要原因。关闭 MIUI 优化后,我发现电池的剩余电量信息没法展示在状态栏电池图标内,一定会出现在电池图标右侧,这对于 HyperOS 出现超级岛(灵动岛)后的状态栏空间是一个极大的浪费。当然,还有一些别的功能也会受到影响,但这是我最在意的一个。</p> <p>另外,在 HyperOS 3 的开发者模式中已经找不到「关闭 MIUI 优化」这个选项了,虽然有用户反馈说可以通过重置设置状态之类的手段来让这个选项重新出现,但我觉得这并不是一个很好的解决方案。</p> <h3>2. 冻结「电量和性能」应用或替换为国际版</h3> <p>在 HyperOS 2 上,有用户反馈对「电量和性能」这个系统应用动刀可以解决 FCM 熄屏断连的问题。我尝试使用 adb shell 来冻结,经过我的测试这并不是一个有用的方案,并且它可能会导致一些系统调度异常,<strong>并且不要进入超级省电模式,因为这个模式的 UI 就是「电量和性能」这个应用提供的!!!</strong></p> <p>adb 命令如下</p> <pre><code class="language-bash">adb shell pm uninstall --user 0 com.miui.powerkeeper </code></pre> <p>如果你想恢复的话,可以通过下面的命令重新安装这个应用:</p> <pre><code class="language-bash">adb shell cmd package install-existing com.miui.powerkeeper </code></pre> <p>还有人反馈说可以通过替换为国际版的「电量和性能」应用来解决这个问题,但我没找到 HyperOS 3 国际版的「电量和性能」应用的安装包,下载小米 15 海外版的完整 Rom 解包后,这个应用的 apk 也没法直接覆盖更新或者通过 adb 安装。</p> <p><img src="https://static.031130.xyz/uploads/2026/02/20/58ba6ce88ef49.webp" alt="覆盖更新失败"></p> <h3>3. 解锁 Bootloader 以后使用 <a href="https://github.com/kooritea/fcmfix">fcmfix</a> 等 Xposed 模块</h3> <p>这个方案就。。。算了吧,虽然我在小米社区有 5 级账号,但我没有这个精力去参加小米高考(听说还停办了),况且解锁 Bootloader 以后可能还会面临支付软件无法使用等一系列问题,如果要掩盖相关痕迹又要折腾一番,感觉得不偿失了。</p> <h3>4. 使用 <a href="https://github.com/shaobin0604/HeartbeatFixerForGCM">HeartbeatFixerForGCM</a></h3> <p>该软件已在 Google Play 下架并且长期没有更新,经测试在 HyperOS 3 没法阻止 FCM 在锁屏状态下被系统断开连接。</p> <h3>5. 使用 Gboard 保活 FCM</h3> <p>虽然不知道是什么原理,但有用户反馈说安装 Gboard 键盘可以让 FCM 在锁屏状态下保持连接并正常接收消息了。我也试了一下,确实在安装了 Gboard 并将它设置为默认输入法后,FCM 在锁屏状态下确实能保持连接了。</p> <p>不过这个方案也不是很完美,毕竟我并不喜欢 Gboard 的输入体验,所以我不太能接受这个方案。</p> <h2>一些个人的小尝试</h2> <p>尽管我对安卓开发没多少经验,只有在大二做课设的时候接触过,但 AI 时代赋予了我 vibe coding 的能力</p> <p><img src="https://static.031130.xyz/uploads/2026/02/20/51db4ed4ef15b.webp" alt="尝试使用 vibe coding 修改源码并编译"></p> <p>所以我也让 AI 基于 HeartbeatFixerForGCM 的开源代码修改了一些保活 FCM 的逻辑,大概是下面这些思路:</p> <ul> <li>输入法保活: 延续 Gboard 的思路,利用输入法服务的常驻特性来保持 FCM 连接,能够保活 FCM,但丢失了输入法的输入能力,pass</li> <li>通知监听:NotificationListener 作为系统监听角色,提升常驻概率,未果,pass</li> <li>前台服务:常驻通知 + FGS 提升进程存活优先级,未果,pass</li> <li>无障碍保活: 无障碍服务同样作为系统监听角色,提升常驻概率,未果,pass</li> <li>VPN 保活:通过 VPN 服务的常驻特性来保持 FCM 连接,不知道有没有效果,<del>但确实给我手机整断网了</del>,pass</li> </ul> <p>总之,这些尝试都没有成功,FCM 在锁屏状态下依然会被系统断开连接。</p> <h2>好像找到一个可行的方案了?</h2> <p>就在我山重水复疑无路,准备物色下一台手机的时候,我看到一篇小红书笔记中提到,可以先卸载更新「Google Play 服务」,再重新更新到最新版的方案。帖主的解释是先把国内优化版的 Play 服务卸载掉,再从 Play Store 安装一个没有被国内优化过的版本,能解决这个问题了。</p> <p>虽然没法在 Play Store 上直接找到「Google Play 服务」这个应用,但可以通过手机浏览器搜索 「Google Play Services」,点开 google.com 上的那个链接,就可以自动跳转到 Play Store 上的「Google Play 服务」应用界面。你也可以直接点<a href="https://play.google.com/store/apps/details?id=com.google.android.gms">这里</a>。</p> <p>我也试了一下,确实在卸载更新「Google Play 服务」以后,FCM 在锁屏状态下就能保持连接了。虽然这个方案听起来有点玄学且我也无法窥见真正起作用的原理,但既然有效果了,我也就不纠结了。</p> <p>但就当我以为问题解决了,在写这篇文章的时候,我尝试重启手机,结果发现问题又回来了。并且在重启后,重复上面的卸载更新「Google Play 服务」的步骤,问题依然没有解决,这让我很苦恼,于是开始回忆之前的操作步骤,但始终没让我再复现之前的状态了。。。</p> <h2>等等,好像有保底</h2> <p>就在我和一位<a href="https://github.com/Rurikobaka/">资深玩机网友</a>讨论这个问题的时候,他提出国内 OS 确实会在熄屏后为了省电而断开 fcm 的长连接,但仍然会保留定时检查的机制,这与我在测试过程中的部分孤例似乎是吻合的。这个定时检查的间隔时长比较长,据他推测在 10~20 分钟左右。我自己也进行了一轮测试,流程是这样的</p> <ol> <li>熄屏一分钟,使用小号往主号发送消息,等待 6 分钟后主号收到消息,通过小米手环的振动通知我消息到了</li> <li>立刻再使用小号往主号发送消息,等待主号第二次收到消息,记录两次消息的时间差。</li> </ol> <p>因为时间间隔比较长,所以我只测试了一轮半,第一轮的时间差是 28 分钟,第二轮的时间差达到了 38 分钟。</p> <p>得出结论:<strong>在熄屏状态下,虽然 FCM 的连接会被系统断开,但系统会每隔 30 分钟左右(不准确数据)自动唤醒一次 FCM 来检查是否有新的消息,如果有的话就会收到通知了。</strong></p> <h2>结论</h2> <p>目前来说,要在国内版的小米手机上接收 FCM 推送,只能在使用 Gboard + 实时推送 / 定时检查机制的保底方案中二选一了。</p> <p>前者通过 Gboard 输入法的常驻特性来保持 FCM 连接,能够在熄屏状态下实时接收消息,但需要牺牲输入体验;后者则是通过系统定时唤醒 FCM 来检查是否有新的消息,虽然不需要牺牲输入体验了,但可能会有超过半小时的延迟。</p> <h2>参见</h2> <ul> <li><a href="https://www.mobile01.com/topicdetail.php?f=634&#x26;t=6892724">【HyperOS】修復小米陸版通知推送 - Mobile01</a></li> <li><a href="https://www.v2ex.com/t/993090">如何解决原生 Android 续航问题? - V2EX</a></li> <li><a href="https://staging.v2ex.com/t/1089681">小米 15/hyperOS 2.0 打开 FCM 通知 - V2EX</a></li> <li><a href="https://www.threads.com/@silicon.salmon/post/DDR_SD8y3Gp">海外不建議購買內地版小米手機 因為一鎖屏就斷連 FCM, 導致海外 App 收不到消息推送。 亮屏後 FCM,會嘗試重連。 重連成功,才能收到通知。 已開自啟動,電池無限制。 有什麼解決辦法? 香港澳門| salmon0105</a></li> <li><a href="https://github.com/shaobin0604/HeartbeatFixerForGCM">shaobin0604/HeartbeatFixerForGCM: Tiny application to fix GCM push notification delay issue</a></li> <li><a href="https://github.com/kooritea/fcmfix">kooritea/fcmfix: [xposed]让fcm唤醒已完全停止的应用</a></li> <li>小红书、小绿书等社交平台上的相关讨论和反馈,因内容较为分散且不太系统,这里就不一一列举了,感兴趣的读者可以自行搜索相关关键词进行查看。</li> </ul>

2026/2/20
阅读更多

我没法访问 dl.google.com —— 记一次 TUN 下的网络 debug

<p>如果大家对目前中国大陆境内的网络环境足够了解,应该就会知道 <code>dl.google.com</code> 在很多情况下是<strong>可以直连访问</strong>的。比如,你可以通过 <a href="https://google.cn/chrome/?standalone=1">google.cn/chrome/?standalone=1</a> 这个 URL 直接在境内的网络环境下下载 Chrome 的离线安装包,最终的下载域名就是 <code>dl.google.com</code>。</p> <p>我平常的使用习惯是 24 小时开启代理工具的 TUN,让所有流量先经过一张虚拟网卡,再根据分流规则自动判断要不要走代理。这个习惯大部分时候都挺省心的——直到最近我用 <code>yay</code> 滚 Arch 的时候,突然遇到了 <code>dl.google.com</code> 的 SSL 连接建立失败。</p> <p><img src="https://static.031130.xyz/uploads/2026/01/31/df5e547511070.webp" alt="yay 更新失败"></p> <p>而且不止是 <code>yay</code>,我的浏览器也返回了相同的结果:</p> <p><img src="https://static.031130.xyz/uploads/2026/01/31/8ada158510aba.webp" alt="Firefox 访问失败"></p> <p>当时我的第一反应是:是不是我那套分流规则又抽风了?(毕竟不是我自己写的,出事先甩锅很合理。)</p> <h2>规则确实是直连,这锅甩不掉</h2> <p>我特意去查了分流规则,针对 SNI 为 <code>dl.google.com</code> 的流量是直连访问的。</p> <p><img src="https://static.031130.xyz/uploads/2026/01/31/96d27a20f2bf3.webp" alt="Mihomo的分流规则"></p> <p>这就很奇怪了。按理说:</p> <ul> <li><code>dl.google.com</code> 本身在国内网络环境里经常是能直连的</li> <li>规则也明确写了 DIRECT</li> </ul> <p>说实话,这个问题我之前也遇到过,但那时手上有优先级更高的事,就直接关掉代理工具绕过了它完成更新。好在我现在刚处理完手头事情,正处于无事可做的状态,于是决定认真把这个坑填了。</p> <h2>解析到海外 IP 了</h2> <p>我先把代理工具的 <code>fake-ip</code> 关掉,换成真实 IP 解析(避免再引入额外变量),然后用 <code>curl -vv</code> 去访问 <code>dl.google.com</code> 的下载链接,看看它到底要连到哪里去。</p> <p><img src="https://static.031130.xyz/uploads/2026/01/31/d3dbfc513de9f.webp" alt="curl -vv 的访问结果"></p> <p>现在回头看我能很笃定地说:这里解析出来的这个 IP 来自 Google 的海外 CDN,而不是国内机房/国内可达的那一类。</p> <p><img src="https://static.031130.xyz/uploads/2026/01/31/7ba8a0b8a2579.webp" alt="image-20260131070318960"></p> <p>如果大家不清楚的话:<code>dl.google.com</code> 针对国内访客的 DNS 解析结果,很多时候会返回国内可达的 IP(否则你也没法在境内直连下载)。而这里返回的这个海外 IP 在我这条网络上是不可达的;再加上我在喵喵工具里给 <code>dl.google.com</code> 配的是直连,于是就变成了:</p> <blockquote> <p>DNS 给了一个「海外 IP」</p> <ul> <li>规则要求 DIRECT = 直连到一个我连不上的地方 = TLS 握手失败</li> </ul> </blockquote> <p>所以这并不是「直连规则没生效」,而更像是:<strong>规则生效得非常彻底,但 DNS 把我带沟里了。</strong></p> <h2>谁在负责回答 dl.google.com?</h2> <p>Mihomo 内核目前的 DNS 配置项主要是下面四个:</p> <ol> <li><code>nameserver</code>: 默认解析服务器(大部分域名都走这里)</li> <li><code>direct-nameserver</code>: 直连域名的解析服务器(较新版本才有)</li> <li><code>proxy-server-nameserver</code>: 节点域名解析(跟这次没啥关系)</li> <li><code>default-nameserver</code>: 用来解析 DNS 配置里「域名形式」的 nameserver(也先不展开)</li> </ol> <p><code>dl.google.com</code> 被规则指定为直连域名,所以 Mihomo 理论上应该优先参考 <code>direct-nameserver</code>;如果没设置,就回落到 <code>nameserver</code>。</p> <p>而我当时的 <code>nameserver</code> 配置是:</p> <ul> <li><code>https://dns.alidns.com/dns-query</code></li> </ul> <p>我当时的直觉很简单:既然解析结果像是从海外 CDN 池里出来的,那就先验证一下是不是这条阿里 DNS(DoH)返回的就是海外 IP。</p> <h2>直接查阿里 DoH,确实回了海外池</h2> <p>阿里 DoH 提供了一个 JSON 查询接口,所以我直接用 curl 去请求:</p> <pre><code class="language-bash">curl -s 'https://dns.alidns.com/resolve?name=dl.google.com&#x26;type=A' </code></pre> <p><img src="https://static.031130.xyz/uploads/2026/01/31/b457af9299330.webp" alt="DoH 解析结果"></p> <p>返回的 IP 就是我之前遇到的那个海外 IP。到这一步我基本可以确认:<strong>至少在我当前这条网络出口下,阿里 DNS 对 <code>dl.google.com</code> 的解析结果就是“那一类”我访问不到的 IP。</strong></p> <h3>这个问题需要两个条件同时成立(缺一不可)</h3> <p>写到这里必须强调一下:这事并不是「阿里 DNS 永远解析错」这么简单,我后来做了一圈对照,发现它其实很“苛刻”:</p> <blockquote> <p>**只有在「移动宽带」+「阿里 DNS(包括 223.5.5.5 或 alidns 的 DoH)」这两个条件同时成立时,问题才可能稳定复现。**两个条件缺一不可。</p> </blockquote> <p>更具体一点就是:</p> <ul> <li><strong>换成电信/联通的宽带</strong>:用同样的阿里 DNS,<code>dl.google.com</code> 的解析结果通常就正常</li> <li><strong>还是移动宽带,但不用阿里 DNS</strong>:解析结果也通常正常</li> <li><strong>移动宽带 + 阿里 DNS</strong>:高概率拿到海外池,然后直连就炸</li> </ul> <p>我也用 itdog 做了下全国解析测试,移动网络下的复现比例确实更高。</p> <p><img src="https://static.031130.xyz/uploads/2026/01/31/d93222096f005.webp" alt="itdog 测试结果"></p> <p>为什么会这样?老实说我没有能力给一个“全网唯一真相”的解释,我只能说现象非常一致,而且足够让我下结论:<strong>问题不是 TUN 本身,而是 TUN 下我的 DNS 选择把 <code>dl.google.com</code> 导向了一个在移动网络里不可达的地址池。</strong></p> <h2>我最后怎么解决的?</h2> <p>既然问题出在「移动宽带 + 阿里 DNS」这个组合上,那解决方式也就很朴素了:<strong>别让 <code>dl.google.com</code> 继续走阿里 DNS 解析。</strong></p> <p>可以配置 direct-nameserver 或者 nameserver-policy,可以配置 119.29.29.29 等其他公共 DNS,或者干脆把 DNS 解析交给家里的路由器。</p> <pre><code class="language-yaml">direct-nameserver: - 192.168.8.1 nameserver-policy: "dl.google.com": [119.29.29.29] </code></pre> <p>这么搞完之后,<code>yay</code> 更新恢复正常,浏览器也能直连访问 <code>dl.google.com</code>。</p> <h2>参见</h2> <ul> <li><a href="https://linux.do/t/topic/1061825">mihomo 内核极简防 DNS 泄漏配置(2025 年) - 开发调优 - LINUX DO</a></li> </ul>

2026/1/31
阅读更多

Vercel 的缓存控制,你注意过吗?

<p>Vercel 默认的缓存配置其实并不合理,但鲜有人注意。</p> <h2>先看效果</h2> <p><img src="https://static.031130.xyz/uploads/2025/12/23/577e579eceb96.webp" alt="图一"></p> <p><img src="https://static.031130.xyz/uploads/2025/12/23/0cfc9543c857a.webp" alt="图二"></p> <h2>分析</h2> <h3>测试方案</h3> <p>这两张图都是我博客在 <a href="https://pagespeed.web.dev/">PageSpeed Insights</a> 上测得的,测试步骤如下:</p> <ol> <li>部署</li> <li>在 PageSpeed Insights 进行第一次测试</li> <li>等待 120s,防止 PageSpeed Insights 拿之前的结果糊弄你</li> <li>进行第二次测试,取第二次测试的结果</li> </ol> <p><strong>取第二次结果的目的是为了</strong>让 PageSpeed Insights 所命中的 Vercel CDN 节点完成回源,并<strong>将内容缓存在 CDN 节点上</strong>,这样第二次访问的时候就会直接从 CDN 的缓存中得到结果,不需要回源。</p> <p>那我们看图一的测试结果,正常吗?针对首页的单个 html 加载时长达到了 <strong>450ms,看着不算慢,但其实细究下来是有问题的。</strong></p> <p>Vercel 采用的是 Amazon 提供的全球 CDN 网络,在我们的首次访问之后,CDN 节点应当该已经缓存了首页的内容,第二次访问的时候应该是直接从 CDN 节点的缓存中获取内容。</p> <h3>合理的时长是多久呢?</h3> <ul> <li>TCP 建立连接的三次握手,需要 1.5 个往返时延(RTT),再加上 TLS 1.3 握手的 1 个 RTT,共计 2.5 个 RTT。</li> <li>HTML 文件大小 18KB,初始拥塞窗口(IW)10 MSS ≈ 1460 字节 ≈ 14.6KB,理论上应该可以在两个 RTT 内传输完毕。</li> </ul> <p>共计 4.5 个 RTT。</p> <p>PageSpeed Insights 测试时使用的节点大概率是在美国,Amazon CDN 在美国的节点覆盖非常广泛,单个 RTT 时长控制在 5ms 以内绰绰有余,所以理论加载时长应该在 22.5ms 左右。加上 DNS 解析时长(这个也不多,因为两分钟前有过一次访问,这次不是冷启动)和一些不可控的网络抖动,<strong>50ms 以内应该是完全没有问题的。</strong></p> <p><strong>但实际测得的时长却高达 450ms,差了近 9 倍,这就很不合理了。</strong></p> <p>我们再来看图二的结果,单个 HTML 加载时长降到了 41ms,完全符合预期。</p> <p>为什么会有这么大的差异呢?原因就在于 Vercel 对缓存控制的设置上。</p> <h2>Vercel 的缓存控制</h2> <p>在 Vercel 上部署的网站,默认情况下,Vercel 会对 HTML 文件设置如下的缓存控制头:</p> <pre><code>cache-control: public, max-age=0, must-revalidate </code></pre> <p>这个设置的含义是:</p> <ul> <li><code>public</code>:响应可以被任何缓存区缓存,包括浏览器和 CDN。</li> <li><code>max-age=0</code>: 响应的最大缓存时间为 0 秒,意味着响应一旦被缓存后立即过期。</li> <li><code>must-revalidate</code>:一旦响应过期,缓存必须向源服务器验证其有效性。</li> </ul> <p>结合这三个指令,Vercel 实际上是告诉 CDN 节点:你可以缓存这个 HTML 文件,但每次在使用缓存之前都必须回源验证其有效性。由于 <code>max-age=0</code>,缓存一旦存储就立即过期,因此每次请求都会触发回源验证。</p> <p>尽管 HTTP/1.1 和 HTTP/2 中的缓存验证通常使用条件请求(如文件的 ETag 或 Last-Modified 头)来节省传输流量,但这仍然需要与源服务器进行往返通信,从而增加了额外的延迟开销。</p> <p>所以,在 Vercel 默认配置下,任何请求的响应都不会被 CDN 节点直接缓存,大致的流程如下:</p> <pre><code class="language-mermaid">sequenceDiagram participant Client participant CDN participant Vercel Client->>CDN: 请求 HTML 文件 CDN->>Vercel: 条件请求验证缓存 Vercel->>CDN: 返回最新的 HTML 文件或 304 Not Modified CDN->>Client: 返回 HTML 文件 </code></pre> <h2>解决方案</h2> <p>要解决这个问题,我们需要调整 Vercel 上的缓存控制设置,使得 HTML 文件能够被 CDN 节点缓存一段时间,而不需要每次都回源验证。</p> <p>Vercel 允许我们在项目的根目录下创建一个 <code>vercel.json</code> 文件以对 Vercel 的部署行为进行一系列的配置,其中就包括 HTTP 的响应头的配置。</p> <p>我的博客采用 Nuxt.js 框架构建,生成的构建产物大概分为两类:</p> <ol> <li>HTML 文件:这些文件的内容可能会频繁变化,不能设置过长的缓存时间;</li> <li>静态资源文件:包括 JavaScript、CSS 等,这些文件的文件名通常带有 hash 值,可以设置较长的缓存时间甚至被标记为不可变(immutable)。</li> </ol> <p>在部署流程上,我的博客在每次推送后会先 Github Actions 中构建成静态页面,再部署到 Vercel 上,所以我在我项目的 public 目录下创建了 <code>vercel.json</code> 文件(这样 vercel.json 文件就会在构建产物的根目录),内容如下:</p> <pre><code class="language-json">{ "headers": [ { "source": "/(.*)", "headers": [ { "key": "Cache-Control", "value": "public, max-age=0, s-maxage=600, must-revalidate" } ] }, { "source": "/(.*)\\.(css|js)", "headers": [ { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } ] } ] } </code></pre> <p>这里我对所有的 CSS 和 JS 文件设置了 <code>max-age=31536000, immutable</code>,这样这些静态资源文件就可以被浏览器和 CDN 长时间缓存。而对于所有其他文件(主要是 HTML 文件),我设置了 <code>max-age=0, s-maxage=600, must-revalidate</code>,这样 HTML 文件就可以被 CDN 缓存 10 分钟,在这 10 分钟内的请求都可以直接从 CDN 节点的缓存中获取内容,而不需要回源验证。</p> <p>这样一来,经过修改后的缓存控制设置,HTML 文件的请求流程变成了:</p> <pre><code class="language-mermaid">sequenceDiagram participant Client participant CDN Client->>CDN: 请求 HTML 文件 CDN->>Client: 直接返回缓存的 HTML 文件 </code></pre> <p>从而大大减少了请求的延迟,提高了页面加载速度。</p> <h2>其他</h2> <p>Vercel 所采用的架构并不是传统的 「源站 - CDN」 架构,而是更接近 「全球多区域存储 + CDN边缘缓存」 的架构,所以即使是回源请求,Vercel 也会尽量从离用户最近的存储节点获取内容,从而减少延迟。<strong>但这并不意味着回源请求的延迟可以忽略不计,尤其是在追求极致的加载速度时,合理的缓存控制仍然是非常重要的。</strong></p> <h2>参见</h2> <ul> <li><a href="https://vercel.com/docs/headers/cache-control-headers">Cache-Control headers | Vercel</a></li> <li><a href="https://vercel.com/docs/cdn-cache">Vercel CDN Cache | Vercel</a></li> <li><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Caching">HTTP caching - HTTP | MDN</a></li> </ul>

2025/12/23
阅读更多

小记 —— Caddy 在 Layer 4 上的流量代理实践

<h2>背景</h2> <p>在我的一台优化线路 vps 上,我的 443 端口要承担两个职责</p> <ol> <li>作为我博客对中国大陆境内访客的服务提供者,同时承担 https 流量加解密和 static server 的职责</li> <li>把某些特殊用途的流量特征通过一些手段伪装成某些知名、常见、且被广泛允许的站点的 https 流量 <span class="heimu">(没错,是 Reality)</span></li> </ol> <p>因此,我需要一个能够在同一台服务器的同一端口上同时处理这两种职责的方案。</p> <h2>方案选择</h2> <p>其实我很早就知道 Nginx 的 stream 关键词可以实现 Layer 4 (即 TCP 字节流原样转发)下基于 SNI 识别实现的分流功能,但我其实一直是 Caddy 的忠实用户,写了不少 <a href="/tags/Caddy/">Caddy 相关的博文</a>。因此,尽管 Nginx 在不久前已经支持了 ACME v2,但 Caddyfile 的简洁和易用性依然让我更倾向于使用 Caddy 来实现这个功能。</p> <p>经过一番查阅,Caddy 最新版本(v2.10)并不支持 Layer 4 的流量代理功能,但有一个名为 <a href="https://github.com/mholt/caddy-l4">caddy-l4</a> 的社区模块可以实现这个功能,在 Github 上有 1.5k stars,且最近也有更新维护,于是我决定尝试使用这个模块来实现我的需求。</p> <h2>安装</h2> <p>尽管 Caddy 官方提供的 APT 源中的 Caddy 版本并不包含 caddy-l4 模块,但我仍然建议先通过 APT 安装 Caddy 的基础版本,然后再通过 Caddy 官方提供的<a href="https://caddyserver.com/download">在线构建</a>页面选择需要的模块来生成自定义的 Caddy 二进制文件,下载后替换掉系统中的 Caddy 可执行文件。这样做的好处是可以方便地完成 systemd 服务的配置。但注意关闭 Caddy 的 APT 源,以免后续自动更新覆盖掉自定义编译的版本。</p> <p>后续更新可以通过</p> <pre><code class="language-bash">caddy upgrade </code></pre> <p>命令来完成,caddy 会自动列出当前二进制文件所包含的模块,并自动触发官网的在线构建来生成新的二进制文件并进行替换,只需手动重启 systemd 服务即可完成更新。</p> <p>如果 Caddy 官方提供的在线构建失败(最近挺不稳定的),可以<a href="https://caddyserver.com/docs/build#xcaddy">参考文档</a>使用 xcaddy 在本地编译 Caddy:</p> <pre><code class="language-bash">xcaddy build --with github.com/mholt/caddy-l4 </code></pre> <h2>配置</h2> <p>这是我先前博客站点的 Caddyfile 配置:</p> <pre><code>zhul.in { root * /var/www/zhul.in encode zstd gzip file_server handle_errors { rewrite * /404.html file_server } } www.zhul.in { redir https://zhul.in{uri} } </code></pre> <p>zhul.in 和 <a href="http://www.zhul.in">www.zhul.in</a> 都占用了 80 和 443 端口,因此需要把这两个站点的 443 端口的监听改到其他端口,把 443 端口交给 caddy-l4 来处理。</p> <p>修改后的 Caddyfile 如下:</p> <pre><code>http://zhul.in:80, https://zhul.in:8443 { root * /var/www/zhul.in encode zstd gzip file_server handle_errors { rewrite * /404.html file_server } } http://www.zhul.in:80, https://www.zhul.in:8443 { redir https://zhul.in{uri} } </code></pre> <p>随后,添加 caddy-l4 的配置:</p> <pre><code>{ layer4 { :443 { @zhulin tls sni zhul.in www.zhul.in route @zhulin { proxy 127.0.0.1:8443 } @proxy tls sni osxapps.itunes.apple.com route @proxy { proxy 127.0.0.1:20443 } } } } </code></pre> <p>这里的写法还挺简单的,首先在 <code>layer4</code> 块中监听 443 端口,然后通过 <code>@name tls sni domain</code> 的方式定义基于 SNI 的匹配规则,随后通过 <code>route @name</code> 定义匹配到该规则时的处理方式,这里使用 <code>proxy ip:port</code> 来实现流量的转发。</p> <p>由于我的妙妙流量伪装成了 Apple 的 itunes 流量,因此在上面的配置中的 SNI 特征是 <code>osxapps.itunes.apple.com</code>,这些流量会被转发到本地的 20443 端口,由另一个奇妙服务来处理。</p> <p>caddy-l4 还提供了一些其他的匹配方式和处理方式,具体可以参考他们在 Github 中给到的 <a href="https://github.com/mholt/caddy-l4/tree/master/docs/examples">examples</a>。</p> <p>完成配置后,重启 Caddy 服务:</p> <pre><code class="language-bash">sudo systemctl restart caddy </code></pre> <h2>参见</h2> <ul> <li><a href="https://github.com/mholt/caddy-l4">mholt/caddy-l4: Layer 4 (TCP/UDP) app for Caddy</a></li> <li><a href="https://caddyserver.com/docs/build">Build from source — Caddy Documentation</a></li> </ul>

2025/12/10
阅读更多

你的域名后缀拖慢你的网站速度了嘛?——再谈 DNS 冷启动

<p>在<a href="/2025/11/11/dns-cold-start-dilemma/">上一篇博客</a>中,我提到过一个核心观点——<strong>对于流量少、访客的地理位置不集中的小型站点,DNS 冷启动不是偶发的“意外”,而是一种被动的“常态”。</strong></p> <p>对于大多数站长而言,自己的站点流量不是一时半刻就能提上去的,因此我们的访客大概率都要走完一遍完整的 DNS 解析过程。上一篇博客中我提到过更改为距离访客物理位置更近的权威 DNS 服务器来提升速度,但 <strong>TLD(域名后缀)的 Nameservers 是我们无法改变的</strong>,也就是下图中红色背景的那一段解析过程。</p> <pre><code class="language-mermaid">sequenceDiagram autonumber participant User as 用户/浏览器 participant Local as 本地DNS&#x3C;br>递归解析器 participant Root as 根域名服务器 participant TLD as 顶级域服务器&#x3C;br>(TLD Server) participant Auth as 权威DNS服务器 Note over User,Auth: DNS 递归查询完整流程 User->>Local: 查询域名 www.example.com Note over Local: 检查缓存 (MISS) Local->>Root: 查询 .com 的 TLD 服务器 Root-->>Local: 返回 .com TLD 服务器地址 %% --- 重点高亮区域开始 --- rect rgb(255, 235, 235) Note right of Local: ⚠️ 本文核心讨论区域 &#x3C;br> (TLD 解析时延) Local->>TLD: 查询 example.com 的权威服务器 Note left of TLD: 这里的物理距离与 Anycast 能力&#x3C;br>决定了是否存在数百毫秒的延迟 TLD-->>Local: 返回 example.com 的权威服务器地址 end %% --- 重点高亮区域结束 --- Local->>Auth: 查询 www.example.com 的 A 记录 Auth-->>Local: 返回 IP 地址 (e.g., 1.1.1.1) Note over Local: 缓存结果 (TTL) Local-->>User: 返回最终 IP 地址 User->>Auth: 建立 TCP 连接 / HTTP 请求 </code></pre> <p>所以,如果你还没有购买域名,但想要像个 geeker 一样追求极致的首屏加载(哪怕你并没有多少访客),你该选择哪个 TLD 呢?</p> <h2>简单测试</h2> <p>一个简单的方法是,直接去 ping TLD 的 nameserver,看看访客所请求的公共 DNS 服务器在这一段解析中所花费的时常。</p> <p>以我的域名 zhul.in 为例,在 Linux 下,可以通过 <code>dig</code> 命令拿到 <code>in</code> 这个 TLD 的 Nameserver</p> <pre><code class="language-bash">dig NS in. </code></pre> <p><img src="https://static.031130.xyz/uploads/2025/11/24/b15e97068bd75.webp" alt="in 的 TLD Nameservers"></p> <p>随后可以挑选任何一个 Nameserver(公共 DNS 服务器其实有一套基于历史性能的选择策略),直接去 ping 这个域名</p> <p><img src="https://static.031130.xyz/uploads/2025/11/24/bed48f4ed30ac.webp" alt="距离 TLD Nameserver 的延迟"></p> <p>我这里的网络环境是杭州移动,如果我在我的局域网开一台 DNS 递归服务器,这个结果就是在上面那张时序图中红色部分所需要时长的最小值(DNS 服务器还需要额外的时长去处理请求)。</p> <p>借助一些网站提供的多个地点 ping 延迟测试,我们可以推测这个 TLD 在全球哪些国家或地区部署了 Anycast(泛播)节点,下图为 iplark.com 提供的结果。</p> <p><img src="https://static.031130.xyz/uploads/2025/11/24/748d25e0beecc.webp" alt="in 的 TLD Nameserver 在全球范围内的 ping 值"></p> <p>可以推测,in 的 TLD Nameserver 起码在日本、香港、美国、加拿大、欧洲、澳大利亚、巴西、印度、南非等多地部署了 Anycast 节点,而在中国大陆境内的延迟较高。</p> <hr> <p>作为对比,我们可以通过同样的方法再看看 cn 域名的 TLD Nameserver 的 Anycast 节点。</p> <p><img src="https://static.031130.xyz/uploads/2025/11/24/9751de16b460b.webp" alt="cn 的 TLD Nameserver 在全球范围内的 ping 值"></p> <p>经过 itdog.cn 的测试,推测 cn 域名的 TLD Nameserver 可能仅在北京有节点。</p> <h2>更进一步的的实验方案</h2> <p>上面的测试方法只是一个简易的判断方法,在现实中会有很多的外部因素影响 DNS 冷启动的解析时长:</p> <ul> <li>公共 DNS 服务器和 TLD Nameserver 之间存在 peer,他们的通信非常快</li> <li>TLD Nameserver 的性能差,需要额外的几十 ms 去处理你的请求</li> <li>TLD 的几个 Nameserver 有快慢之分,而你选用的公共 DNS 服务器能根据历史数据选择较快的那个</li> <li>...</li> </ul> <p>所以,我们需要有一个基于真实的 DNS 解析请求的测试方案</p> <p>对于 DNS 冷启动相关的测试一直以来存在一个困境——公共 DNS 服务器不归我们管,我们无法登陆上去手动清除它的缓存,因此所有的测试都只有第一次结果才可能有效,后续的请求会直接打到缓存上。但这一次我们测试的是公共 DNS 服务器到 TLD Nameserver 这一段的延迟,在 Gemini 的提醒下,我意识到可以在不同地区测试公共 DNS 对随机的、不存在的域名的解析时长,这能够反应不同 TLD 之间的差异。</p> <p>所以,测试代码在下面,你可以使用常见的 Linux 使用 bash 执行这段代码,需要确保装有 dig 和 shasum 命令,并且推荐使用 screen / tmux 等工具挂在后台,因为整个测试过程可能会持续十几分钟。如果你所采用的网络环境在中国大陆境内,我建议你把代码中的公共 DNS 服务器换成 223.5.5.5 / 119.29.29.29 ,应该会更符合境内访客的使用环境。</p> <pre><code class="language-bash">#!/bin/bash # ================= 配置区域 ================= # CSV 文件名 OUTPUT_FILE="dns_benchmark_results.csv" # DNS 服务器 DNS_SERVER="8.8.8.8" # 待测试的 TLD 列表 # 包含:全球通用(com), 国别(cn, de), 热门技术(io, xyz), 以及可能较慢的后缀 TLDS_TO_TEST=("com" "net" "org" "cn" "in" "de" "cc" "site" "ai" "io" "xyz" "top") # 每个 TLD 测试次数 SAMPLES=1000 # 每次查询间隔 (秒),防止被 DNS 服务器判定为攻击 # 1000次 * 0.1s = 100秒/TLD,总耗时约 15-20 分钟 SLEEP_INTERVAL=0.1 # =========================================== # 初始化 CSV 文件头 echo "TLD,Domain,QueryTime_ms,Status,Timestamp" > "$OUTPUT_FILE" echo "=============================================" echo " DNS TLD Latency Benchmark Tool" echo " Target DNS: $DNS_SERVER" echo " Samples per TLD: $SAMPLES" echo " Output File: $OUTPUT_FILE" echo "=============================================" echo "" # 定义进度条函数 function show_progress { # 参数: $1=当前进度, $2=总数, $3=当前TLD, $4=当前平均耗时 let _progress=(${1}*100/${2}) let _done=(${_progress}*4)/10 let _left=40-$_done # 构建填充字符串 _fill=$(printf "%${_done}s") _empty=$(printf "%${_left}s") # \r 让光标回到行首,实现刷新效果 printf "\rProgress [${_fill// /#}${_empty// /-}] ${_progress}%% - Testing .${3} (Avg: ${4}ms) " } # 主循环 for tld in "${TLDS_TO_TEST[@]}"; do # 统计变量初始化 total_time_accum=0 valid_count=0 for (( i=1; i&#x3C;=${SAMPLES}; i++ )); do # 1. 生成随机域名 (防止缓存命中) # 使用 date +%N (纳秒) 确保足够随机,兼容 Linux/macOS RAND_PART=$(date +%s%N | shasum | head -c 10) DOMAIN="test-${RAND_PART}.${tld}" TIMESTAMP=$(date "+%Y-%m-%d %H:%M:%S") # 2. 执行查询 # +tries=1 +time=2: 尝试1次,超时2秒,避免脚本卡死 result=$(dig @${DNS_SERVER} ${DOMAIN} A +noall +stats +time=2 +tries=1) # 提取时间 (Query time: 12 msec) query_time=$(echo "$result" | grep "Query time" | awk '{print $4}') # 提取状态 (status: NXDOMAIN, NOERROR, etc.) status=$(echo "$result" | grep "status:" | awk '{print $6}' | tr -d ',') # 3. 数据清洗与记录 if [[ -n "$query_time" &#x26;&#x26; "$query_time" =~ ^[0-9]+$ ]]; then # 写入 CSV echo "${tld},${DOMAIN},${query_time},${status},${TIMESTAMP}" >> "$OUTPUT_FILE" # 更新统计 total_time_accum=$((total_time_accum + query_time)) valid_count=$((valid_count + 1)) current_avg=$((total_time_accum / valid_count)) else # 记录失败/超时 echo "${tld},${DOMAIN},-1,TIMEOUT,${TIMESTAMP}" >> "$OUTPUT_FILE" current_avg="N/A" fi # 4. 显示进度条 show_progress $i $SAMPLES $tld $current_avg sleep $SLEEP_INTERVAL done # 每个 TLD 完成后换行 echo "" echo "✅ Completed .${tld} | Final Avg: ${current_avg} ms" echo "---------------------------------------------" done echo "🎉 All Done! Results saved to $OUTPUT_FILE" </code></pre> <h2>测试结果</h2> <p><strong>免责声明:以下测试结果仅供参考,不构成任何购买推荐,且仅代表测试当日(2025.11.24)的网络情况,后续不会进行跟进。DNS 冷启动对于大型站点几乎没有影响,仅小站需要关注。本次测试中,所有境内检测点使用 223.5.5.5 作为 DNS 服务器,境外检测点使用 8.8.8.8。</strong></p> <table> <thead> <tr> <th>测试点/延迟(ms)</th> <th>.com</th> <th>.net</th> <th>.org</th> <th>.cn</th> <th>.in</th> <th>.de</th> <th>.cc</th> <th>.site</th> <th>.ai</th> <th>.io</th> <th>.xyz</th> <th>.top</th> </tr> </thead> <tbody> <tr> <td>🇨🇳 上海腾讯云</td> <td>438</td> <td>429</td> <td>470</td> <td>30</td> <td>535</td> <td>353</td> <td>476</td> <td>454</td> <td>367</td> <td>485</td> <td>444</td> <td>43</td> </tr> <tr> <td>🇨🇳 北京腾讯云</td> <td>425</td> <td>443</td> <td>469</td> <td>17</td> <td>350</td> <td>420</td> <td>466</td> <td>647</td> <td>582</td> <td>461</td> <td>559</td> <td>9</td> </tr> <tr> <td>🇭🇰 香港 Yxvm</td> <td>75</td> <td>75</td> <td>363</td> <td>227</td> <td>6</td> <td>11</td> <td>61</td> <td>6</td> <td>33</td> <td>126</td> <td>5</td> <td>7</td> </tr> <tr> <td>🇨🇳 彰化(台湾) Hinet</td> <td>90</td> <td>87</td> <td>128</td> <td>213</td> <td>59</td> <td>38</td> <td>76</td> <td>37</td> <td>73</td> <td>94</td> <td>36</td> <td>47</td> </tr> <tr> <td>🇯🇵 大阪 Vmiss</td> <td>20</td> <td>19</td> <td>244</td> <td>309</td> <td>15</td> <td>24</td> <td>17</td> <td>35</td> <td>19</td> <td>65</td> <td>37</td> <td>90</td> </tr> <tr> <td>🇸🇬 新加坡 Wap</td> <td>6</td> <td>9</td> <td>139</td> <td>398</td> <td>6</td> <td>10</td> <td>7</td> <td>17</td> <td>7</td> <td>110</td> <td>17</td> <td>66</td> </tr> <tr> <td>🇺🇸 洛杉矶 ColoCrossing</td> <td>7</td> <td>7</td> <td>307</td> <td>137</td> <td>4</td> <td>64</td> <td>5</td> <td>62</td> <td>5</td> <td>49</td> <td>47</td> <td>231</td> </tr> <tr> <td>🇩🇪 杜塞尔多夫 WIIT AG</td> <td>16</td> <td>17</td> <td>288</td> <td>82</td> <td>75</td> <td>15</td> <td>14</td> <td>24</td> <td>66</td> <td>73</td> <td>24</td> <td>306</td> </tr> <tr> <td>🇦🇺 悉尼 Oracle</td> <td>33</td> <td>31</td> <td>12</td> <td>338</td> <td>7</td> <td>13</td> <td>121</td> <td>7</td> <td>10</td> <td>9</td> <td>7</td> <td>191</td> </tr> </tbody> </table> <p>通过上面的数据,我们可以看到 .cn 和 .top 是所有测试的域名后缀中在中国大陆境内解析速度最快的,但选择 .cn 和 .top 意味着你需要牺牲其他地区访客的解析速度。而像 .com、.net、.org 这些通用的域名后缀在全球绝大部分地区表现良好,而在中国大陆境内的解析速度则相对较慢,因为他们没有在大陆境内部署 Anycast 节点。在 DNS 冷启动的场景下(如果你的站点访客少,那几乎每次访问都是冷启动),首屏加载时间会因此增加 500ms 甚至更多。</p> <p><strong>经 v2ex 的网友 <a href="https://www.v2ex.com/member/Showfom">Showfom</a> 提醒,GoDaddy 作为注册局掌握的部分 TLD 的 Nameserver 同样在中国大路境内拥有 Anycast 节点,比如 .one、.tv、.moe 等。另, Amazon Registry Services 旗下的 .you 域名经我测试也有境内的 Anycast 节点。其他域名后缀可自行测试。</strong></p> <p>你可以点击<a href="https://static.031130.xyz/bin/dns_benchmark_results_20251124.tar.zst">这里</a>下载完整的测试结果 CSV 文件进行进一步的分析。</p>

2025/11/25
阅读更多

DNS 冷启动:小型站点的“西西弗斯之石”

<p>当我们谈论网站性能时,我们通常关注前端渲染、资源懒加载、服务器响应时间(TTFB)等。然而,在用户浏览器真正开始请求内容之前,有一个至关重要却鲜少在性能优化方面被提及的部分—— DNS 解析。对于默默无闻的小型站点而言,“DNS Cache Miss”(缓存未命中)或我称之为“DNS 冷启动”,会成为绕不过去的性能瓶颈,也就是本文标题所提到的“西西弗斯之石”。</p> <h2>神话的隐喻:DNS 解析的漫长旅程</h2> <p>要理解这块“石头”的重量,我们必须重温 DNS 解析的完整路径。这并非一次简单的查找,而是一场跨越全球的接力赛:</p> <ol> <li><strong>起点:公共 DNS 服务器</strong> — 用户发出请求,公共 DNS 服务器尝试在缓存中寻找答案。</li> <li><strong>首次“推石”:根服务器</strong> — 缓存缺失(Cache Miss),公共 DNS 服务器被引向全球 13 组根服务器。</li> <li><strong>第二程:TLD 服务器</strong> — 根服务器指向特定后缀(如 <code>.com</code>)的顶级域名服务器。</li> <li><strong>第三程:权威服务器</strong> — TLD 服务器指向网站域名最终的“管家”——权威 DNS 服务器。</li> <li><strong>终点:</strong> 权威服务器返回最终的 IP 地址,再由公共 DNS 服务器返回给用户。</li> </ol> <pre><code class="language-mermaid">sequenceDiagram participant User as 用户/浏览器 participant Local as 本地DNS&#x3C;br>递归解析器 participant Root as 根域名服务器 participant TLD as 顶级域服务器&#x3C;br>(.com, .org等) participant Auth as 权威DNS服务器 Note over User,Auth: DNS递归查询完整流程 User->>Local: 1. 查询域名&#x3C;br>www.example.com Note over Local: 检查缓存&#x3C;br>未找到记录 Local->>Root: 2. 查询 .com 的TLD服务器 Root-->>Local: 3. 返回 .com TLD服务器地址 Local->>TLD: 4. 查询 example.com 的权威服务器 TLD-->>Local: 5. 返回 example.com 的权威服务器地址 Local->>Auth: 6. 查询 www.example.com 的A记录 Auth-->>Local: 7. 返回 IP地址 (e.g., 1.1.1.1) Note over Local: 缓存结果&#x3C;br>(根据TTL设置) Local-->>User: 8. 返回最终IP地址 Note over User,Auth: 后续流程 User->>Auth: 9. 使用IP地址建立TCP连接&#x3C;br>开始HTTP请求 </code></pre> <p>对于<strong>首次</strong>或<strong>长时间未访问</strong>的请求,这个过程意味着至少 4 次网络往返(RTT),而在涉及到 CNAME 等情况时则会更多。对于那些拥有完美缓存的大型网站来说,这块石头可能已被别人推到了山顶;但对小型站点,它总是在山脚等待它的西西弗斯。</p> <h2>多重世界:Anycast 的镜像迷宫</h2> <p>“既然 DNS 冷启动的代价如此之高,那我能否使用脚本定时访问自己的网站,提前让公共 DNS 缓存预热起来呢?”——这是我曾经设想的解题思路。</p> <p>然而,这一思路在现代互联网的 Anycast(泛播)架构下,往往徒劳无功。</p> <p>Anycast 的核心理念是:同一个 IP 地址在全球多个节点同时存在,用户请求会被路由到“距离最近”或“网络路径最优”的节点。</p> <p>这意味着,Google DNS (8.8.8.8) 、Cloudflare DNS (1.1.1.1)、阿里 DNS (223.5.5.5)、腾讯 DNS (119.29.29.29) 等公共 DNS 服务器背后并不是一台中心化的服务器,而是一组分布在世界各地、动态路由的节点集群。</p> <p>于是问题出现了:</p> <ul> <li>我在上海运行的预热脚本,也许命中了 223.5.5.5 的上海节点;</li> <li>但来自北京的访问者,却会被路由到 223.5.5.5 的北京节点;</li> <li>这两个节点的缓存,<strong>彼此独立、互不共享。</strong></li> </ul> <p>从站长的视角来看,DNS 缓存不再是一个可预测的实体,而是分裂成一片片地理隔离、随时可变的“镜像迷宫”。</p> <p>每个访客都在不同的山脚下推着自己的那块石头,仿佛世界上有成千上万个西西弗斯,孤独地在各自的路径上前行。</p> <h2>不可控的缓存与「冷启动的常态化」</h2> <p>这也解释了为什么即便一个小型网站有规律地被脚本访问,仍可能在真实访客那里出现明显的 DNS 延迟。因为「预热」只是局部生效 —— 它温暖的是某一个任播节点的缓存,而不是整个网络的全貌。而当 TTL 到期或缓存被公共 DNS 服务器采用 LRU 等算法清理时,这份温度也会悄然散去。</p> <p>从宏观上看,这让“小流量站点”陷入了某种宿命循环:</p> <ol> <li>因访问量低,缓存不易命中;</li> <li>因缓存不命中,解析耗时高;</li> <li>因解析耗时高,首屏性能差,用户更少访问;</li> <li>因用户更少访问,缓存更难命中。</li> </ol> <p><strong>冷启动不再是偶发的“意外”,而是一种被动的“常态”。</strong></p> <h2>我们能否让石头变轻?—— 减缓冷启动影响的策略</h2> <p>西西弗斯的困境看似无解,但我们并非完全无能为力。虽然无法彻底消除 DNS 冷启动,但通过一系列策略,我们可以显著减轻这块石头的重量,缩短它每次滚落后被推上山顶的时间。</p> <h3>权衡的艺术:调整 DNS TTL (Time-To-Live)</h3> <p>TTL(生存时间)是 DNS 记录中的一个关键值,它告知递归解析器(如公共 DNS、本地缓存)可以将一条解析记录缓存多久,尽管他们可能会被 LRU 算法淘汰。</p> <p>拉长 TTL 可以有效提高缓存的命中率,减少 DNS 冷启动的情况,尽可能让西西弗斯之石保留在山顶上。</p> <p>但拉长 TTL 是以牺牲灵活性作为代价的:如果你因为某些原因需要更换域名做对应的 IP 地址,过长的 TTL 可能会导致访客在很长一段时间内取得的都是已经失效的 IP 地址。</p> <h3>选择更快的“信使”:使用合适的权威 DNS 服务器</h3> <p>DNS 解析的最后一公里——从公共 DNS 服务器到你的权威 DNS 服务器——的耗时同样至关重要。如果你的域名所采用的 Nameserver 服务响应缓慢、全球节点稀少、又或者距离访客所请求的公共 DNS 服务器距离太远,那么即使用户的公共 DNS 节点就在身边,整个解析链条依然会被这最后一环拖慢。</p> <p>如果我正在写的是一篇英文博客,那么我只需要说把 Nameserver 换成 Cloudflare、Google 等一线大厂就完事了。这些大厂提供免费的权威 DNS 托管业务,且在全球各地拥有大量节点,在这方面是非常专业且值得信赖的。</p> <p>但我现在正在使用简体中文,根据我的博客统计数据,我的读者大多来自中国大陆,他们的站点访客大多也来自中国大陆,他们请求的公共 DNS 服务器大概率也都部署在中国大陆,而 Cloudflare/Google Cloud DNS 完全没有权威 DNS 服务器的中国大陆节点,这会拖慢速度。所以<strong>如果你的访客主要来自中国大陆境内,或许可以试试阿里云或者 Dnspod</strong>,他们主要的权威 DNS 服务器节点都在中国大陆境内,这在理论上可以减少公共 DNS 服务器与 权威 DNS 服务器之间的通信时长。</p> <h2>结语:推石头的人</h2> <p>DNS 冷启动的问题,从未有完美的解决方案。它像是互联网架构中注定存在的一段“延迟的诗意”——每个访问者都从自己的网络拓扑出发,沿着看不见的路径,一步步推着那块属于自己的石头,直到抵达你的服务器山顶,换得屏幕上第一个像素的亮起。</p> <p>对小型站点而言,这或许是命运的重量;但理解它、优化它、监测它,便是我们在这条漫长上坡路上,为石头磨出更光滑的棱角。</p> <h2>参见</h2> <ul> <li><a href="https://developers.google.com/speed/public-dns/docs/performance">Performance Benefits | Public DNS | Google for Developers</a></li> <li><a href="https://falconcloud.ae/about/blog/how-do-dns-queries-affect-website-latency/">How do DNS queries affect website latency? - falconcloud.ae</a></li> </ul>

2025/11/11
阅读更多

HTTP/2 Server Push 已事实性“死亡”,我很怀念它

<p>我最近一阵子在重构我的博客,恰巧之前一阵子准备秋招的时候背八股时看到了 HTTP/2 的服务端推送,于是便尝试在部署阶段为我的博客配置好 HTTP/2 的服务端推送,试图以此来进一步优化首屏加载速度。</p> <h2>HTTP/2 服务端推送为什么能提升首屏加载速度</h2> <p>如下图,在传统的 HTTP/1.1 中,浏览器会先下载 <code>index.html</code> 并完成第一轮解析,然后再从解析出的数据中拿到 css/js 资源的 url,再进行第二轮请求,在 tcp/tls 连接建立后最小需要<strong>两个 RTT 才能取回</strong>完整渲染页面所需的资源。</p> <pre><code class="language-mermaid">sequenceDiagram participant Browser participant Server Browser->>Server: GET /index.html Server-->>Browser: 200 OK + HTML Browser->>Server: GET /style.css Browser->>Server: GET /app.js Server-->>Browser: 200 OK + CSS Server-->>Browser: 200 OK + JS Note over Browser: 浏览器必须等 HTML 下载并解析后&#x3C;br/>才能发起后续请求,增加往返延迟 (RTT) </code></pre> <p>而在 HTTP/2 的设想中,流程则是像下面这张图一样。当浏览器请求 index.html 时,服务端可以顺带将 css/js 资源一起推送给客户端,这样在 tcp/tls 连接建立后最小只需要<strong>一个 RTT 就可以</strong>将页面渲染所需的资源取回。</p> <pre><code class="language-mermaid">sequenceDiagram participant Browser participant Server Browser->>Server: GET /index.html Server-->>Browser: 200 OK + HTML Server-->>Browser: PUSH_PROMISE /style.css Server-->>Browser: PUSH_PROMISE /app.js Server-->>Browser: (推送) style.css + app.js 内容 Note over Browser: 浏览器收到资源前置推送&#x3C;br/>减少请求轮次与首屏延迟 </code></pre> <p>为了在 HTTP/1.1 中尽可能减少后续的请求,前端开发者尝试了非常多的优化手段,正如 Sukka 在《<a href="https://blog.skk.moe/post/http2-server-push/">静态资源递送优化:HTTP/2 和 Server Push</a>》一文中所讲:</p> <blockquote> <p>关键资源、关键渲染路径、关键请求链的概念诞生已久,异步加载资源的概念可谓是老生常谈:懒加载图片、视频、iframe,乃至懒加载 CSS、JS、DOM,懒执行函数。但是,关键资源递送的思路却依然没有多少改变。</p> </blockquote> <p>HTTP/2 的 Server Push 创造了新的资源递送思路,CSS/JS 等资源不用随着 html 一起递送也能在一个 RTT 内被传送到客户端,而这一部分资源可以被浏览器缓存起来,不被 html 那较短的 TTL 所限制。</p> <h2>初步方案</h2> <p>既然理清了 HTTP/2 服务端推送的优势,于是准备着手优化。我的博客是纯静态的,通过 DNS 进行境内外分流:境内流量会访问到 DMIT 一台带有 cmin2/9929 网络优化的 vps 上,通过 caddy 提供服务;境外流量则是直接打到 vercel,借助 Amazon 的 CDN 为全球网络提供边缘加速。网络架构大概是下面这个样子:</p> <pre><code class="language-mermaid">graph TD A[博客访客] --> B[发起DNS解析请求] B --> C[DNSPod 服务] C -->|境内访客:智能分流| D[网络优化 VPS] C -->|境外访客:智能分流| E[Vercel 平台] D --> F[Caddy] F --> G[返回博客内容给境内访客] E --> H[返回博客内容给境外访客] </code></pre> <p>Caddy 可以通过 <code>http.handlers.push</code> 模块实现 HTTP/2 的服务端推送,在 Caddyfile 中我们可以编写简单的推送逻辑,这没问题;vercel 平台则没有给开发者提供 HTTP/2 服务端推送的配置项,但好在我是静态博客,对平台依赖性不强,考虑迁移到 Cloudflare Workers,<a href="https://brianli.com/cloudflare-workers-sites-http2-server-push/">五年前就有开发者实现了</a>。</p> <h2>客户端的支持情况</h2> <p>历史上主流浏览器引擎(Chrome/Chromium、Firefox、Edge、Safari)曾普遍支持服务器推送技术。</p> <p>2020 年 11 月,谷歌宣布<a href="https://groups.google.com/a/chromium.org/g/blink-dev/c/K3rYLvmQUBY/m/vOWBKZGoAQAJ">计划在其 Chrome 浏览器的 HTTP/2 及 gQUIC(后发展为HTTP/3)实现中移除服务器推送功能</a>。</p> <p>2022 年 10 月,谷歌宣布<a href="https://developer.chrome.com/blog/removing-push/">计划从Chrome浏览器中移除服务器推送功能</a>,指出该扩展在实际应用中性能不佳、使用率低且存在更优替代方案。Chrome 106 成为首个默认禁用服务器推送的版本。</p> <p>2024 年 10 月 29 日,Mozilla 发布了 Firefox 132版本,<a href="https://www.firefox.com/en-US/firefox/132.0/releasenotes/">因“与多个网站存在兼容性问题”移除了对HTTP/2服务器推送功能的支持。</a></p> <p>至此,主流浏览器对 HTTP/2 服务端推送(Server Push)的支持已全部终结。从最初被视为“减少往返延迟、优化首屏加载”的创新特性,到最终被全面弃用,HTTP/2 推送的生命周期不过短短数年,成为 Web 性能优化历史上的一次重要实验。</p> <h2>替代方案</h2> <h3>1. HTTP 103 Early Hints</h3> <p>103 Early Hints 是对服务端推送最直接的“继任者”。它是一个信息性的 HTTP 状态码 (Informational Response),允许服务器在生成完整的 HTML 响应(例如,状态码为 200 OK)之前,先发送一个带有 Link 头部的“早期提示”响应。</p> <p>这个 Link 头部可以告诉浏览器:“嘿,我还在准备主菜(HTML),但你可以先去把配菜(CSS、JS)准备好”。这样,浏览器就能利用服务器的“思考时间”提前开始下载关键资源或预热到所需源的连接,从而显著缩短首屏渲染时间。</p> <p>与服务端推送的对比:</p> <ul> <li>决策权在客户端:Early Hints 只是“提示”,浏览器可以根据自身缓存情况、网络状况等因素决定是否采纳该提示。这就解决了服务端推送最大的痛点——服务器无法知晓客户端缓存而导致推送冗余资源。</li> <li>兼容性更好:它是一种更轻量、更易于中间代理服务器理解和传递的机制。</li> </ul> <p>103 Early Hints 对动态博客很有意义,在后端进行计算之前先把需要的资源通过 103 响应告知浏览器,让浏览器先取回其他资源,再等待后端返回最终的 html;而<strong>对于</strong>我这种产物都是预构建好的<strong>静态博客</strong>,完全<strong>没有任何意义</strong>,网关有那个发 103 响应的闲工夫完全可以把 html 直接发过去了。</p> <h3>2. 资源提示(Resource Hints): Preload &#x26; Prefetch</h3> <p>早在服务端推送被弃用前,通过 <code>&#x3C;link></code>标签实现的资源提示就已经是前端性能优化的常用手段。它们将资源加载的提示声明在 HTML 中,由浏览器主导整个过程。</p> <ul> <li><code>&#x3C;link rel="preload"></code>: 用于告诉浏览器当前页面必定会用到的资源,请以高优先级立即开始加载,但加载后不执行。比如,隐藏在 CSS 深处的字体文件或由 JS 动态加载的首屏图片。通过 Preload,可以确保这些关键资源能尽早被发现和下载,避免渲染阻塞。</li> <li><code>&#x3C;link rel="prefetch"></code>: 用于告诉浏览器用户在未来可能访问的页面或用到的资源,请在浏览器空闲时以低优先级在后台下载。例如,在文章列表页 prefetch 用户最可能点击的文章页面的资源,从而实现近乎“秒开”的跳转体验。</li> </ul> <p>Preload 和 Prefetch 将资源加载的控制权完全交给了开发者和浏览器,通过声明式的方式精细化管理资源加载的优先级和时机,是目前最成熟、应用最广泛的资源预加载方案,但仍然<strong>逃不过 2 RTT 的魔咒</strong>。</p> <h2>尾声:写给一个理想主义者的挽歌</h2> <p>写到最后,我终究是没能为我的博客配上 HTTP/2 服务端推送。</p> <p><strong>HTTP/2 Server Push 已事实性“死亡”,我很怀念它。</strong></p> <p>在一个理想模型里,当浏览器请求 HTML 时,服务器顺手将渲染所需的 CSS 和 JS 一并推来,将原本至少两次的往返(RTT)干脆利落地压缩为一次。这是一个如此直接、如此漂亮的解决方案,几乎是前端工程师面对首屏渲染延迟问题时梦寐以求的“银弹”。它背后蕴含的是一种雄心勃勃的魄力:试图由服务端一次性地、彻底地解决“关键请求链”的延迟问题。</p> <p>但 Web 的世界终究不是一个理想的实验室。它充满了缓存、重复访问的用户、以及形形色色的网络环境。</p> <p>服务端推送最大的魅力,在于它的“主动”,而它最大的遗憾,也恰恰源于这份“主动”。它无法知晓浏览器缓存中是否早已静静躺着那个它正准备满腔热情推送的 style.css 文件。为了那一小部分首次访问用户的极致体验,却可能要以浪费更多再次访问用户的宝贵带宽为代价。</p> <p>Web 的演进最终选择了一条更稳妥、更具协作精神的道路。它将决策权交还给了最了解情况的浏览器,整个交互从服务器的“<strong>我推送给你</strong>”,变成了服务器的“<strong>我建议你拿</strong>”,再由浏览器自己定夺。这或许不够浪漫,不够极致,但它更普适,也更健壮。</p> <p>所以,我依然会怀念那个雄心勃勃的 Server Push。它代表了一种对极致性能的纯粹追求,一种美好的技术理想主义。尽管它已悄然淡出历史舞台,但它所指向的那个关于“速度”的梦想,早已被 103 Early Hints 和 preload 以一种更成熟、更懂得权衡的方式继承了下来。</p> <h2>参见</h2> <ul> <li><a href="https://developer.chrome.com/blog/removing-push">Remove HTTP/2 Server Push from Chrome | Blog | Chrome for Developers</a></li> <li><a href="https://en.wikipedia.org/wiki/HTTP/2_Server_Push">HTTP/2 Server Push - Wikipedia</a></li> <li><a href="https://blog.skk.moe/post/http2-server-push/">静态资源递送优化:HTTP/2 和 Server Push | Sukka's Blog</a></li> <li><a href="https://caddyserver.com/docs/modules/http.handlers.push">Module http.handlers.push - Caddy Documentation</a></li> <li><a href="https://brianli.com/cloudflare-workers-sites-http2-server-push/">How to Configure HTTP/2 Server Push on Cloudflare Workers Sites</a></li> <li><a href="https://groups.google.com/a/chromium.org/g/blink-dev/c/K3rYLvmQUBY/m/vOWBKZGoAQAJ">Intent to Remove: HTTP/2 and gQUIC server push</a></li> </ul>

2025/11/5
阅读更多

推荐订阅

Chen's Blog,分享安全领域的所思、所想、所学。

空鸣深语

无论你是游戏死忠,还是轻度的休闲玩家,在这里都能找到感兴趣的东西。