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

明明 SSG 出来的纯静态产物,HTML 里 <div id="__nuxt"> 都齐齐整整,凭什么客户端不认账?
useAsyncData 是怎么命中 payload 的——key 一致就读 payload,不一致就重跑 fetch。那只能是 key 不一致。把构建产物的 _payload.json 抠出来看:
{"randomIndex/about": ...}
key 是 randomIndex/about,没有尾斜杠。可这个文件本身在 .output/public/about/_payload.json,浏览器访问的 URL 是 /about/,客户端 route.path 拼出来的 key 是 randomIndex/about/——多了一个斜杠。
Math.random() 在客户端重跑,DOM 与服务端渲染对不上,水合崩盘,整页 re-render。
罪魁祸首就是 route.path 在 SSR / client 两端因为 Nitro prerender 的归一化时机而不一致。修起来不难——useAsyncData 的 key 改用 route.name + params,跟路径解耦就完了。
修完才想起来,地址栏里那个没斜杠的 URL 还在挂着,于是又顺手把 client middleware 也加上,把"用户输错斜杠"这条路径也一并接住。
完了发现这是个值得正经写一篇下来留底的事——所以才有了这篇文章。
2026-04-30 08:00:00
前几天深夜 0 点,躺在床上百无聊赖刷着京东,居然奇迹般地逮到了这台常年断货的 Xiaomi Book Pro 14 2026 顶配版的现货!这突然弹出来的购买按钮,简直像是在跟我招手。虽然脑子里天人交战了一番,最后我的手还是非常诚实地按下了付款键。
虽说错过了首发折扣,原价 10499 让人隐隐有些肉疼,但好在还能叠一波本地的国补,实付 8999 拿下的价格也算是“真香”了。仔细想想一点也不亏,毕竟也是时候让我那台任劳任怨、被我折腾了四年的老伙计 ThinkPad T14 Gen2i 顺理成章地退居二线享清福啦。
这台本子在近一个月的自媒体测评中表现亮眼,我也不过多介绍了,总之其搭载的 Panther Lake 提供的性能提升和续航表现都非常不错,再加上其 1.07kg 的轻巧重量,让我非常心动。剩下唯一的不确定因素就是它的 Linux 兼容性了。由于是新机,在加上其搭载的 Panther Lake 处理器产能极低,所以没指望有 Linux 用户能第一时间上手测试。看了看是 Intel 的平台,网卡也是 Intel 的,所以我决定赌一把。到货后不直接激活,通过 oobe\bypassnro 来跳过微软账户的绑定,直接进入系统桌面后关闭快速启动,便掏出 LiveCD 来测试。

我是这样打算的:如果能进入 LiveCD,经过简单的测试后发现没有什么大问题的话,就确认收货、联网激活;如果确实是 Linux 兼容性有问题且短时间内无法解决的话,就只能退货了,因为我确实是个重度 Linux 用户。
测试项如下:
注:需要提前向大家说明的是,对于 指纹识别 以及 电源管理(包括系统的睡眠、休眠等状态切换) 相关的特性,在本次实测中我并未作专门验证,主要考虑到我自己日常在 Linux 下基本不会用到指纹等功能,所以在这方面没有过多纠结。
好在核心基础配置的测试结果都没辜负我的期望,我简单讲讲几个 LiveCD 的测试结果
| 发行版 | 内核版本 | 结果 |
|---|---|---|
| Ubuntu 26.04 | 7.0 | 键盘不识别,内置屏幕偶有花屏 |
| Fedora 44 | 6.18 | 键盘不识别,内置屏幕偶有花屏,无声音 |
| CachyOS 260426 | 6.18 | 键盘不识别,内置屏幕偶有花屏,无声音 |
从上面的测试结果可以看出,在 7.0 以上的内核版本修复了声音的问题,而我在一番搜索后找到了这样一篇博客能够解决键盘不识别和内置屏幕偶有花屏的问题。
重新进入 LiveCD,在 GRUB 界面按 e 进入编辑模式,在 linux 行的末尾添加 i8042.nopnp=1 i8042.dumbkbd=1 xe.force_probe=b081 i915.force_probe=!b081 xe.enable_psr=0 参数后按 F10 启动,进入系统后键盘就能正常使用了,内置屏幕偶有花屏的问题也不再出现了。于是我连上 WIFI 放了几个小时的 YouTube 8K 视频,没出现什么明显的问题,蓝牙、声卡、摄像头经测试也都正常工作。
这样一来,这台 Xiaomi Book Pro 14 2026 的 Linux 兼容性可以说是超出预期的,在正式安装系统时只需要添加几段内核参数就能解决之前测试中遇到的几个问题。
于是便是迁移过程,这部分内容就不展开了,简单来说就是新建 Linux 根目录分区、通过 rsync 将老的系统迁移过来、重建 fstab 分区表和 GRUB 引导、最后安装好系统后再添加之前提到的内核参数,重启后就能正常使用了。
rsync 的命令大概是这样的:
rsync -axHAWXS --numeric-ids <source> <destination>

总的来说,这台 Xiaomi Book Pro 14 2026 在 Linux 上的兼容性表现还是非常不错的,虽然在初始测试中遇到了一些问题,但通过添加内核参数后这些问题都得到了有效解决。我目前已经联网激活自带的 Windows 系统并确认收货,同时也成功迁移了 Linux 系统,整体体验非常满意。
2026-02-20 08:00:00
在去年的 11 月,我入手了一台新的国行版的小米 15 作为我的主力机。其实之前手上的 Redmi K70 Ultra 与这台小米 15 是同一年发布的产品,性能上也不相上下,但我在 Gap Year 期间在全国多地旅行,意识到没能将自己所见的夜景拍下是一件挺可惜的事,于是就想换一台拍照更好的手机。新发布的小米 17 在价格上正处于高位,且相对于 15 来说并没有太大的提升,所以我就直接入了小米 15。
入手以后,我按照自己的使用习惯打开了设置中的「谷歌基础服务」,安装好「Google Play」后便正常使用。但就在这几个月的使用过程中,我发现我的 fcm 推送——无论是 outlook 邮件通知,还是某外观形似纸飞机的即时通讯软件,他们的 fcm 推送好像自始至终都没有正常 work 过。它们的通知偶尔会在我解锁手机后突然冒出来,但更多的时候它们就是不见了。一直要到我手动打开对应的 app,才会收到之前积压的通知。
最近收到了海外院校的 offer,准备在 9 月份去读水硕了,所以就想在出国之前把这个问题解决掉。毕竟在国内生活的时候,大部分软件能通过 mipush 给我推送消息,这些依赖 fcm 推送的 app 并不是我使用频率最高的 app,我每天睡醒手动打开一下就能收到通知了,即使回复消息的及时性不太好也无伤大雅,真有急事的话我的家人朋友们也知道哪些方式能更快的 reach 我。但到了国外,fcm 推送就变得非常重要了,所以要尽快处理掉这个问题,要不然我就得考虑换手机了。
我需要先解决 FCM 在亮屏状态下也无法接收消息的问题。在「电话」界面的拨号盘输入 *#*#426#*#* 可以进入 FCM Diagnostics 界面,里面有一些关于 FCM 连接状态的日志输出。


在这个界面中,我发现 FCM 的连接状态其实是正常的,日志里也没有什么明显的错误信息。于是我就开始怀疑是不是 HyperOS 的某些省电机制在后台干扰了 FCM 的正常工作。通过在小红书的一番搜索,我发现在设置界面打开需要 FCM 推送的 App 的「自启动」权限,并且把电池优化设置为「不优化」,似乎能解决这个问题。

通过 IM 软件(用一个号给另一个号发消息)测试,我发现在亮屏状态下,FCM 可以在 App 后台被关闭的情况下正常接收消息了。
然而,当我把手机熄屏后静置一分钟后,FCM 就完全不工作了。无论是邮件通知还是 IM 消息,都无法通过 FCM 推送到手机上。只有在我点亮屏幕的一瞬间,之前积压的通知才会突然冒出来。再通过 FCM Diagnostics 界面查看,FCM 的连接状态要么是 disconnected,要么是刚刚 connected 几秒。这说明在锁屏状态下,FCM 的连接会被系统断开,导致无法接收消息。
PS: 我发现在充电状态下,即使手机锁屏,FCM 也能保持连接并正常接收消息。这就更加印证了是 HyperOS 的省电机制在干扰 FCM 的正常工作了。
在小红书和小绿书(酷安)一番搜索后,我找到了前人的一些尝试和解决方案:
这是我最不喜欢的方案,因为 MIUI 优化确实是我喜欢 HyperOS (MIUI) 的一个重要原因。关闭 MIUI 优化后,我发现电池的剩余电量信息没法展示在状态栏电池图标内,一定会出现在电池图标右侧,这对于 HyperOS 出现超级岛(灵动岛)后的状态栏空间是一个极大的浪费。当然,还有一些别的功能也会受到影响,但这是我最在意的一个。
另外,在 HyperOS 3 的开发者模式中已经找不到「关闭 MIUI 优化」这个选项了,虽然有用户反馈说可以通过重置设置状态之类的手段来让这个选项重新出现,但我觉得这并不是一个很好的解决方案。
在 HyperOS 2 上,有用户反馈对「电量和性能」这个系统应用动刀可以解决 FCM 熄屏断连的问题。我尝试使用 adb shell 来冻结,经过我的测试这并不是一个有用的方案,并且它可能会导致一些系统调度异常,并且不要进入超级省电模式,因为这个模式的 UI 就是「电量和性能」这个应用提供的!!!
adb 命令如下
adb shell pm uninstall --user 0 com.miui.powerkeeper
如果你想恢复的话,可以通过下面的命令重新安装这个应用:
adb shell cmd package install-existing com.miui.powerkeeper
还有人反馈说可以通过替换为国际版的「电量和性能」应用来解决这个问题,但我没找到 HyperOS 3 国际版的「电量和性能」应用的安装包,下载小米 15 海外版的完整 Rom 解包后,这个应用的 apk 也没法直接覆盖更新或者通过 adb 安装。

这个方案就。。。算了吧,虽然我在小米社区有 5 级账号,但我没有这个精力去参加小米高考(听说还停办了),况且解锁 Bootloader 以后可能还会面临支付软件无法使用等一系列问题,如果要掩盖相关痕迹又要折腾一番,感觉得不偿失了。
该软件已在 Google Play 下架并且长期没有更新,经测试在 HyperOS 3 没法阻止 FCM 在锁屏状态下被系统断开连接。
虽然不知道是什么原理,但有用户反馈说安装 Gboard 键盘可以让 FCM 在锁屏状态下保持连接并正常接收消息了。我也试了一下,确实在安装了 Gboard 并将它设置为默认输入法后,FCM 在锁屏状态下确实能保持连接了。
不过这个方案也不是很完美,毕竟我并不喜欢 Gboard 的输入体验,所以我不太能接受这个方案。
尽管我对安卓开发没多少经验,只有在大二做课设的时候接触过,但 AI 时代赋予了我 vibe coding 的能力

所以我也让 AI 基于 HeartbeatFixerForGCM 的开源代码修改了一些保活 FCM 的逻辑,大概是下面这些思路:
总之,这些尝试都没有成功,FCM 在锁屏状态下依然会被系统断开连接。
就在我山重水复疑无路,准备物色下一台手机的时候,我看到一篇小红书笔记中提到,可以先卸载更新「Google Play 服务」,再重新更新到最新版的方案。帖主的解释是先把国内优化版的 Play 服务卸载掉,再从 Play Store 安装一个没有被国内优化过的版本,能解决这个问题了。
虽然没法在 Play Store 上直接找到「Google Play 服务」这个应用,但可以通过手机浏览器搜索 「Google Play Services」,点开 google.com 上的那个链接,就可以自动跳转到 Play Store 上的「Google Play 服务」应用界面。你也可以直接点这里。
我也试了一下,确实在卸载更新「Google Play 服务」以后,FCM 在锁屏状态下就能保持连接了。虽然这个方案听起来有点玄学且我也无法窥见真正起作用的原理,但既然有效果了,我也就不纠结了。
但就当我以为问题解决了,在写这篇文章的时候,我尝试重启手机,结果发现问题又回来了。并且在重启后,重复上面的卸载更新「Google Play 服务」的步骤,问题依然没有解决,这让我很苦恼,于是开始回忆之前的操作步骤,但始终没让我再复现之前的状态了。。。
就在我和一位资深玩机网友讨论这个问题的时候,他提出国内 OS 确实会在熄屏后为了省电而断开 fcm 的长连接,但仍然会保留定时检查的机制,这与我在测试过程中的部分孤例似乎是吻合的。这个定时检查的间隔时长比较长,据他推测在 10~20 分钟左右。我自己也进行了一轮测试,流程是这样的
因为时间间隔比较长,所以我只测试了一轮半,第一轮的时间差是 28 分钟,第二轮的时间差达到了 38 分钟。
得出结论:在熄屏状态下,虽然 FCM 的连接会被系统断开,但系统会每隔 30 分钟左右(不准确数据)自动唤醒一次 FCM 来检查是否有新的消息,如果有的话就会收到通知了。
目前来说,要在国内版的小米手机上接收 FCM 推送,只能在使用 Gboard + 实时推送 / 定时检查机制的保底方案中二选一了。
前者通过 Gboard 输入法的常驻特性来保持 FCM 连接,能够在熄屏状态下实时接收消息,但需要牺牲输入体验;后者则是通过系统定时唤醒 FCM 来检查是否有新的消息,虽然不需要牺牲输入体验了,但可能会有超过半小时的延迟。
2026-01-31 08:00:00
如果大家对目前中国大陆境内的网络环境足够了解,应该就会知道 dl.google.com 在很多情况下是可以直连访问的。比如,你可以通过 google.cn/chrome/?standalone=1 这个 URL 直接在境内的网络环境下下载 Chrome 的离线安装包,最终的下载域名就是 dl.google.com。
我平常的使用习惯是 24 小时开启代理工具的 TUN,让所有流量先经过一张虚拟网卡,再根据分流规则自动判断要不要走代理。这个习惯大部分时候都挺省心的——直到最近我用 yay 滚 Arch 的时候,突然遇到了 dl.google.com 的 SSL 连接建立失败。

而且不止是 yay,我的浏览器也返回了相同的结果:

当时我的第一反应是:是不是我那套分流规则又抽风了?(毕竟不是我自己写的,出事先甩锅很合理。)
我特意去查了分流规则,针对 SNI 为 dl.google.com 的流量是直连访问的。

这就很奇怪了。按理说:
dl.google.com 本身在国内网络环境里经常是能直连的说实话,这个问题我之前也遇到过,但那时手上有优先级更高的事,就直接关掉代理工具绕过了它完成更新。好在我现在刚处理完手头事情,正处于无事可做的状态,于是决定认真把这个坑填了。
我先把代理工具的 fake-ip 关掉,换成真实 IP 解析(避免再引入额外变量),然后用 curl -vv 去访问 dl.google.com 的下载链接,看看它到底要连到哪里去。

现在回头看我能很笃定地说:这里解析出来的这个 IP 来自 Google 的海外 CDN,而不是国内机房/国内可达的那一类。

如果大家不清楚的话:dl.google.com 针对国内访客的 DNS 解析结果,很多时候会返回国内可达的 IP(否则你也没法在境内直连下载)。而这里返回的这个海外 IP 在我这条网络上是不可达的;再加上我在喵喵工具里给 dl.google.com 配的是直连,于是就变成了:
DNS 给了一个「海外 IP」
- 规则要求 DIRECT = 直连到一个我连不上的地方 = TLS 握手失败
所以这并不是「直连规则没生效」,而更像是:规则生效得非常彻底,但 DNS 把我带沟里了。
Mihomo 内核目前的 DNS 配置项主要是下面四个:
nameserver: 默认解析服务器(大部分域名都走这里)direct-nameserver: 直连域名的解析服务器(较新版本才有)proxy-server-nameserver: 节点域名解析(跟这次没啥关系)default-nameserver: 用来解析 DNS 配置里「域名形式」的 nameserver(也先不展开)dl.google.com 被规则指定为直连域名,所以 Mihomo 理论上应该优先参考 direct-nameserver;如果没设置,就回落到 nameserver。
而我当时的 nameserver 配置是:
https://dns.alidns.com/dns-query我当时的直觉很简单:既然解析结果像是从海外 CDN 池里出来的,那就先验证一下是不是这条阿里 DNS(DoH)返回的就是海外 IP。
阿里 DoH 提供了一个 JSON 查询接口,所以我直接用 curl 去请求:
curl -s 'https://dns.alidns.com/resolve?name=dl.google.com&type=A'

返回的 IP 就是我之前遇到的那个海外 IP。到这一步我基本可以确认:至少在我当前这条网络出口下,阿里 DNS 对 dl.google.com 的解析结果就是“那一类”我访问不到的 IP。
写到这里必须强调一下:这事并不是「阿里 DNS 永远解析错」这么简单,我后来做了一圈对照,发现它其实很“苛刻”:
**只有在「移动宽带」+「阿里 DNS(包括 223.5.5.5 或 alidns 的 DoH)」这两个条件同时成立时,问题才可能稳定复现。**两个条件缺一不可。
更具体一点就是:
dl.google.com 的解析结果通常就正常我也用 itdog 做了下全国解析测试,移动网络下的复现比例确实更高。

为什么会这样?老实说我没有能力给一个“全网唯一真相”的解释,我只能说现象非常一致,而且足够让我下结论:问题不是 TUN 本身,而是 TUN 下我的 DNS 选择把 dl.google.com 导向了一个在移动网络里不可达的地址池。
既然问题出在「移动宽带 + 阿里 DNS」这个组合上,那解决方式也就很朴素了:别让 dl.google.com 继续走阿里 DNS 解析。
可以配置 direct-nameserver 或者 nameserver-policy,可以配置 119.29.29.29 等其他公共 DNS,或者干脆把 DNS 解析交给家里的路由器。
direct-nameserver:
- 192.168.8.1
nameserver-policy:
"dl.google.com": [119.29.29.29]
这么搞完之后,yay 更新恢复正常,浏览器也能直连访问 dl.google.com。
2025-12-23 08:00:00
Vercel 默认的缓存配置其实并不合理,但鲜有人注意。


这两张图都是我博客在 PageSpeed Insights 上测得的,测试步骤如下:
取第二次结果的目的是为了让 PageSpeed Insights 所命中的 Vercel CDN 节点完成回源,并将内容缓存在 CDN 节点上,这样第二次访问的时候就会直接从 CDN 的缓存中得到结果,不需要回源。
那我们看图一的测试结果,正常吗?针对首页的单个 html 加载时长达到了 450ms,看着不算慢,但其实细究下来是有问题的。
Vercel 采用的是 Amazon 提供的全球 CDN 网络,在我们的首次访问之后,CDN 节点应当该已经缓存了首页的内容,第二次访问的时候应该是直接从 CDN 节点的缓存中获取内容。
共计 4.5 个 RTT。
PageSpeed Insights 测试时使用的节点大概率是在美国,Amazon CDN 在美国的节点覆盖非常广泛,单个 RTT 时长控制在 5ms 以内绰绰有余,所以理论加载时长应该在 22.5ms 左右。加上 DNS 解析时长(这个也不多,因为两分钟前有过一次访问,这次不是冷启动)和一些不可控的网络抖动,50ms 以内应该是完全没有问题的。
但实际测得的时长却高达 450ms,差了近 9 倍,这就很不合理了。
我们再来看图二的结果,单个 HTML 加载时长降到了 41ms,完全符合预期。
为什么会有这么大的差异呢?原因就在于 Vercel 对缓存控制的设置上。
在 Vercel 上部署的网站,默认情况下,Vercel 会对 HTML 文件设置如下的缓存控制头:
cache-control: public, max-age=0, must-revalidate
这个设置的含义是:
public:响应可以被任何缓存区缓存,包括浏览器和 CDN。max-age=0: 响应的最大缓存时间为 0 秒,意味着响应一旦被缓存后立即过期。must-revalidate:一旦响应过期,缓存必须向源服务器验证其有效性。结合这三个指令,Vercel 实际上是告诉 CDN 节点:你可以缓存这个 HTML 文件,但每次在使用缓存之前都必须回源验证其有效性。由于 max-age=0,缓存一旦存储就立即过期,因此每次请求都会触发回源验证。
尽管 HTTP/1.1 和 HTTP/2 中的缓存验证通常使用条件请求(如文件的 ETag 或 Last-Modified 头)来节省传输流量,但这仍然需要与源服务器进行往返通信,从而增加了额外的延迟开销。
所以,在 Vercel 默认配置下,任何请求的响应都不会被 CDN 节点直接缓存,大致的流程如下:
sequenceDiagram
participant Client
participant CDN
participant Vercel
Client->>CDN: 请求 HTML 文件
CDN->>Vercel: 条件请求验证缓存
Vercel->>CDN: 返回最新的 HTML 文件或 304 Not Modified
CDN->>Client: 返回 HTML 文件
要解决这个问题,我们需要调整 Vercel 上的缓存控制设置,使得 HTML 文件能够被 CDN 节点缓存一段时间,而不需要每次都回源验证。
Vercel 允许我们在项目的根目录下创建一个 vercel.json 文件以对 Vercel 的部署行为进行一系列的配置,其中就包括 HTTP 的响应头的配置。
我的博客采用 Nuxt.js 框架构建,生成的构建产物大概分为两类:
在部署流程上,我的博客在每次推送后会先 Github Actions 中构建成静态页面,再部署到 Vercel 上,所以我在我项目的 public 目录下创建了 vercel.json 文件(这样 vercel.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"
}
]
}
]
}
这里我对所有的 CSS 和 JS 文件设置了 max-age=31536000, immutable,这样这些静态资源文件就可以被浏览器和 CDN 长时间缓存。而对于所有其他文件(主要是 HTML 文件),我设置了 max-age=0, s-maxage=600, must-revalidate,这样 HTML 文件就可以被 CDN 缓存 10 分钟,在这 10 分钟内的请求都可以直接从 CDN 节点的缓存中获取内容,而不需要回源验证。
这样一来,经过修改后的缓存控制设置,HTML 文件的请求流程变成了:
sequenceDiagram
participant Client
participant CDN
Client->>CDN: 请求 HTML 文件
CDN->>Client: 直接返回缓存的 HTML 文件
从而大大减少了请求的延迟,提高了页面加载速度。
Vercel 所采用的架构并不是传统的 「源站 - CDN」 架构,而是更接近 「全球多区域存储 + CDN边缘缓存」 的架构,所以即使是回源请求,Vercel 也会尽量从离用户最近的存储节点获取内容,从而减少延迟。但这并不意味着回源请求的延迟可以忽略不计,尤其是在追求极致的加载速度时,合理的缓存控制仍然是非常重要的。
2025-12-10 08:00:00
在我的一台优化线路 vps 上,我的 443 端口要承担两个职责
因此,我需要一个能够在同一台服务器的同一端口上同时处理这两种职责的方案。
其实我很早就知道 Nginx 的 stream 关键词可以实现 Layer 4 (即 TCP 字节流原样转发)下基于 SNI 识别实现的分流功能,但我其实一直是 Caddy 的忠实用户,写了不少 Caddy 相关的博文。因此,尽管 Nginx 在不久前已经支持了 ACME v2,但 Caddyfile 的简洁和易用性依然让我更倾向于使用 Caddy 来实现这个功能。
经过一番查阅,Caddy 最新版本(v2.10)并不支持 Layer 4 的流量代理功能,但有一个名为 caddy-l4 的社区模块可以实现这个功能,在 Github 上有 1.5k stars,且最近也有更新维护,于是我决定尝试使用这个模块来实现我的需求。
尽管 Caddy 官方提供的 APT 源中的 Caddy 版本并不包含 caddy-l4 模块,但我仍然建议先通过 APT 安装 Caddy 的基础版本,然后再通过 Caddy 官方提供的在线构建页面选择需要的模块来生成自定义的 Caddy 二进制文件,下载后替换掉系统中的 Caddy 可执行文件。这样做的好处是可以方便地完成 systemd 服务的配置。但注意关闭 Caddy 的 APT 源,以免后续自动更新覆盖掉自定义编译的版本。
后续更新可以通过
caddy upgrade
命令来完成,caddy 会自动列出当前二进制文件所包含的模块,并自动触发官网的在线构建来生成新的二进制文件并进行替换,只需手动重启 systemd 服务即可完成更新。
如果 Caddy 官方提供的在线构建失败(最近挺不稳定的),可以参考文档使用 xcaddy 在本地编译 Caddy:
xcaddy build --with github.com/mholt/caddy-l4
这是我先前博客站点的 Caddyfile 配置:
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}
}
zhul.in 和 www.zhul.in 都占用了 80 和 443 端口,因此需要把这两个站点的 443 端口的监听改到其他端口,把 443 端口交给 caddy-l4 来处理。
修改后的 Caddyfile 如下:
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}
}
随后,添加 caddy-l4 的配置:
{
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
}
}
}
}
这里的写法还挺简单的,首先在 layer4 块中监听 443 端口,然后通过 @name tls sni domain 的方式定义基于 SNI 的匹配规则,随后通过 route @name 定义匹配到该规则时的处理方式,这里使用 proxy ip:port 来实现流量的转发。
由于我的妙妙流量伪装成了 Apple 的 itunes 流量,因此在上面的配置中的 SNI 特征是 osxapps.itunes.apple.com,这些流量会被转发到本地的 20443 端口,由另一个奇妙服务来处理。
caddy-l4 还提供了一些其他的匹配方式和处理方式,具体可以参考他们在 Github 中给到的 examples。
完成配置后,重启 Caddy 服务:
sudo systemctl restart caddy