MoreRSS

site iconZhulin | 竹林里有冰修改

大三学生,技术博主,Fedora与Arch用户,Hexo撰稿人。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Zhulin | 竹林里有冰的 RSS 预览

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

2025-12-10 08:00:00

背景

在我的一台优化线路 vps 上,我的 443 端口要承担两个职责

  1. 作为我博客对中国大陆境内访客的服务提供者,同时承担 https 流量加解密和 static server 的职责
  2. 把某些特殊用途的流量特征通过一些手段伪装成某些知名、常见、且被广泛允许的站点的 https 流量 (没错,是 Reality)

因此,我需要一个能够在同一台服务器的同一端口上同时处理这两种职责的方案。

方案选择

其实我很早就知道 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

参见

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

2025-11-25 08:00:00

上一篇博客中,我提到过一个核心观点——对于流量少、访客的地理位置不集中的小型站点,DNS 冷启动不是偶发的“意外”,而是一种被动的“常态”。

对于大多数站长而言,自己的站点流量不是一时半刻就能提上去的,因此我们的访客大概率都要走完一遍完整的 DNS 解析过程。上一篇博客中我提到过更改为距离访客物理位置更近的权威 DNS 服务器来提升速度,但 TLD(域名后缀)的 Nameservers 是我们无法改变的,也就是下图中红色背景的那一段解析过程。

sequenceDiagram
    autonumber
    participant User as 用户/浏览器
    participant Local as 本地DNS<br>递归解析器
    participant Root as 根域名服务器
    participant TLD as 顶级域服务器<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: ⚠️ 本文核心讨论区域 <br> (TLD 解析时延)
        Local->>TLD: 查询 example.com 的权威服务器

        Note left of TLD: 这里的物理距离与 Anycast 能力<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 请求

所以,如果你还没有购买域名,但想要像个 geeker 一样追求极致的首屏加载(哪怕你并没有多少访客),你该选择哪个 TLD 呢?

简单测试

一个简单的方法是,直接去 ping TLD 的 nameserver,看看访客所请求的公共 DNS 服务器在这一段解析中所花费的时常。

以我的域名 zhul.in 为例,在 Linux 下,可以通过 dig 命令拿到 in 这个 TLD 的 Nameserver

dig NS in.

in 的 TLD Nameservers

随后可以挑选任何一个 Nameserver(公共 DNS 服务器其实有一套基于历史性能的选择策略),直接去 ping 这个域名

距离 TLD Nameserver 的延迟

我这里的网络环境是杭州移动,如果我在我的局域网开一台 DNS 递归服务器,这个结果就是在上面那张时序图中红色部分所需要时长的最小值(DNS 服务器还需要额外的时长去处理请求)。

借助一些网站提供的多个地点 ping 延迟测试,我们可以推测这个 TLD 在全球哪些国家或地区部署了 Anycast(泛播)节点,下图为 iplark.com 提供的结果。

in 的 TLD Nameserver 在全球范围内的 ping 值

可以推测,in 的 TLD Nameserver 起码在日本、香港、美国、加拿大、欧洲、澳大利亚、巴西、印度、南非等多地部署了 Anycast 节点,而在中国大陆境内的延迟较高。


作为对比,我们可以通过同样的方法再看看 cn 域名的 TLD Nameserver 的 Anycast 节点。

cn 的 TLD Nameserver 在全球范围内的 ping 值

经过 itdog.cn 的测试,推测 cn 域名的 TLD Nameserver 可能仅在北京有节点。

更进一步的的实验方案

上面的测试方法只是一个简易的判断方法,在现实中会有很多的外部因素影响 DNS 冷启动的解析时长:

  • 公共 DNS 服务器和 TLD Nameserver 之间存在 peer,他们的通信非常快
  • TLD Nameserver 的性能差,需要额外的几十 ms 去处理你的请求
  • TLD 的几个 Nameserver 有快慢之分,而你选用的公共 DNS 服务器能根据历史数据选择较快的那个
  • ...

所以,我们需要有一个基于真实的 DNS 解析请求的测试方案

对于 DNS 冷启动相关的测试一直以来存在一个困境——公共 DNS 服务器不归我们管,我们无法登陆上去手动清除它的缓存,因此所有的测试都只有第一次结果才可能有效,后续的请求会直接打到缓存上。但这一次我们测试的是公共 DNS 服务器到 TLD Nameserver 这一段的延迟,在 Gemini 的提醒下,我意识到可以在不同地区测试公共 DNS 对随机的、不存在的域名的解析时长,这能够反应不同 TLD 之间的差异。

所以,测试代码在下面,你可以使用常见的 Linux 使用 bash 执行这段代码,需要确保装有 dig 和 shasum 命令,并且推荐使用 screen / tmux 等工具挂在后台,因为整个测试过程可能会持续十几分钟。如果你所采用的网络环境在中国大陆境内,我建议你把代码中的公共 DNS 服务器换成 223.5.5.5 / 119.29.29.29 ,应该会更符合境内访客的使用环境。

#!/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<=${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" && "$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"

测试结果

免责声明:以下测试结果仅供参考,不构成任何购买推荐,且仅代表测试当日(2025.11.24)的网络情况,后续不会进行跟进。DNS 冷启动对于大型站点几乎没有影响,仅小站需要关注。本次测试中,所有境内检测点使用 223.5.5.5 作为 DNS 服务器,境外检测点使用 8.8.8.8。

测试点/延迟(ms) .com .net .org .cn .in .de .cc .site .ai .io .xyz .top
🇨🇳 上海腾讯云 438 429 470 30 535 353 476 454 367 485 444 43
🇨🇳 北京腾讯云 425 443 469 17 350 420 466 647 582 461 559 9
🇭🇰 香港 Yxvm 75 75 363 227 6 11 61 6 33 126 5 7
🇨🇳 彰化(台湾) Hinet 90 87 128 213 59 38 76 37 73 94 36 47
🇯🇵 大阪 Vmiss 20 19 244 309 15 24 17 35 19 65 37 90
🇸🇬 新加坡 Wap 6 9 139 398 6 10 7 17 7 110 17 66
🇺🇸 洛杉矶 ColoCrossing 7 7 307 137 4 64 5 62 5 49 47 231
🇩🇪 杜塞尔多夫 WIIT AG 16 17 288 82 75 15 14 24 66 73 24 306
🇦🇺 悉尼 Oracle 33 31 12 338 7 13 121 7 10 9 7 191

通过上面的数据,我们可以看到 .cn 和 .top 是所有测试的域名后缀中在中国大陆境内解析速度最快的,但选择 .cn 和 .top 意味着你需要牺牲其他地区访客的解析速度。而像 .com、.net、.org 这些通用的域名后缀在全球绝大部分地区表现良好,而在中国大陆境内的解析速度则相对较慢,因为他们没有在大陆境内部署 Anycast 节点。

经 v2ex 的网友 Showfom 提醒,GoDaddy 作为注册局掌握的部分 TLD 的 Nameserver 同样在中国大路境内拥有 Anycast 节点,比如 .one、.tv、.moe 等。另, Amazon Registry Services 旗下的 .you 域名经我测试也有境内的 Anycast 节点。其他域名后缀可自行测试。

你可以点击这里下载完整的测试结果 CSV 文件进行进一步的分析。

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

2025-11-11 08:00:00

当我们谈论网站性能时,我们通常关注前端渲染、资源懒加载、服务器响应时间(TTFB)等。然而,在用户浏览器真正开始请求内容之前,有一个至关重要却鲜少在性能优化方面被提及的部分—— DNS 解析。对于默默无闻的小型站点而言,“DNS Cache Miss”(缓存未命中)或我称之为“DNS 冷启动”,会成为绕不过去的性能瓶颈,也就是本文标题所提到的“西西弗斯之石”。

神话的隐喻:DNS 解析的漫长旅程

要理解这块“石头”的重量,我们必须重温 DNS 解析的完整路径。这并非一次简单的查找,而是一场跨越全球的接力赛:

  1. 起点:公共 DNS 服务器 — 用户发出请求,公共 DNS 服务器尝试在缓存中寻找答案。
  2. 首次“推石”:根服务器 — 缓存缺失(Cache Miss),公共 DNS 服务器被引向全球 13 组根服务器。
  3. 第二程:TLD 服务器 — 根服务器指向特定后缀(如 .com)的顶级域名服务器。
  4. 第三程:权威服务器 — TLD 服务器指向网站域名最终的“管家”——权威 DNS 服务器。
  5. 终点: 权威服务器返回最终的 IP 地址,再由公共 DNS 服务器返回给用户。
sequenceDiagram
    participant User as 用户/浏览器
    participant Local as 本地DNS<br>递归解析器
    participant Root as 根域名服务器
    participant TLD as 顶级域服务器<br>(.com, .org等)
    participant Auth as 权威DNS服务器

    Note over User,Auth: DNS递归查询完整流程

    User->>Local: 1. 查询域名<br>www.example.com
    Note over Local: 检查缓存<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: 缓存结果<br>(根据TTL设置)

    Local-->>User: 8. 返回最终IP地址

    Note over User,Auth: 后续流程
    User->>Auth: 9. 使用IP地址建立TCP连接<br>开始HTTP请求

对于首次长时间未访问的请求,这个过程意味着至少 4 次网络往返(RTT),而在涉及到 CNAME 等情况时则会更多。对于那些拥有完美缓存的大型网站来说,这块石头可能已被别人推到了山顶;但对小型站点,它总是在山脚等待它的西西弗斯。

多重世界:Anycast 的镜像迷宫

“既然 DNS 冷启动的代价如此之高,那我能否使用脚本定时访问自己的网站,提前让公共 DNS 缓存预热起来呢?”——这是我曾经设想的解题思路。

然而,这一思路在现代互联网的 Anycast(泛播)架构下,往往徒劳无功。

Anycast 的核心理念是:同一个 IP 地址在全球多个节点同时存在,用户请求会被路由到“距离最近”或“网络路径最优”的节点。

这意味着,Google DNS (8.8.8.8) 、Cloudflare DNS (1.1.1.1)、阿里 DNS (223.5.5.5)、腾讯 DNS (119.29.29.29) 等公共 DNS 服务器背后并不是一台中心化的服务器,而是一组分布在世界各地、动态路由的节点集群。

于是问题出现了:

  • 我在上海运行的预热脚本,也许命中了 223.5.5.5 的上海节点;
  • 但来自北京的访问者,却会被路由到 223.5.5.5 的北京节点;
  • 这两个节点的缓存,彼此独立、互不共享。

从站长的视角来看,DNS 缓存不再是一个可预测的实体,而是分裂成一片片地理隔离、随时可变的“镜像迷宫”。

每个访客都在不同的山脚下推着自己的那块石头,仿佛世界上有成千上万个西西弗斯,孤独地在各自的路径上前行。

不可控的缓存与「冷启动的常态化」

这也解释了为什么即便一个小型网站有规律地被脚本访问,仍可能在真实访客那里出现明显的 DNS 延迟。因为「预热」只是局部生效 —— 它温暖的是某一个任播节点的缓存,而不是整个网络的全貌。而当 TTL 到期或缓存被公共 DNS 服务器采用 LRU 等算法清理时,这份温度也会悄然散去。

从宏观上看,这让“小流量站点”陷入了某种宿命循环:

  1. 因访问量低,缓存不易命中;
  2. 因缓存不命中,解析耗时高;
  3. 因解析耗时高,首屏性能差,用户更少访问;
  4. 因用户更少访问,缓存更难命中。

冷启动不再是偶发的“意外”,而是一种被动的“常态”。

我们能否让石头变轻?—— 减缓冷启动影响的策略

西西弗斯的困境看似无解,但我们并非完全无能为力。虽然无法彻底消除 DNS 冷启动,但通过一系列策略,我们可以显著减轻这块石头的重量,缩短它每次滚落后被推上山顶的时间。

权衡的艺术:调整 DNS TTL (Time-To-Live)

TTL(生存时间)是 DNS 记录中的一个关键值,它告知递归解析器(如公共 DNS、本地缓存)可以将一条解析记录缓存多久,尽管他们可能会被 LRU 算法淘汰。

拉长 TTL 可以有效提高缓存的命中率,减少 DNS 冷启动的情况,尽可能让西西弗斯之石保留在山顶上。

但拉长 TTL 是以牺牲灵活性作为代价的:如果你因为某些原因需要更换域名做对应的 IP 地址,过长的 TTL 可能会导致访客在很长一段时间内取得的都是已经失效的 IP 地址。

选择更快的“信使”:使用合适的权威 DNS 服务器

DNS 解析的最后一公里——从公共 DNS 服务器到你的权威 DNS 服务器——的耗时同样至关重要。如果你的域名所采用的 Nameserver 服务响应缓慢、全球节点稀少、又或者距离访客所请求的公共 DNS 服务器距离太远,那么即使用户的公共 DNS 节点就在身边,整个解析链条依然会被这最后一环拖慢。

如果我正在写的是一篇英文博客,那么我只需要说把 Nameserver 换成 Cloudflare、Google 等一线大厂就完事了。这些大厂提供免费的权威 DNS 托管业务,且在全球各地拥有大量节点,在这方面是非常专业且值得信赖的。

但我现在正在使用简体中文,根据我的博客统计数据,我的读者大多来自中国大陆,他们的站点访客大多也来自中国大陆,他们请求的公共 DNS 服务器大概率也都部署在中国大陆,而 Cloudflare/Google Cloud DNS 完全没有权威 DNS 服务器的中国大陆节点,这会拖慢速度。所以如果你的访客主要来自中国大陆境内,或许可以试试阿里云或者 Dnspod,他们主要的权威 DNS 服务器节点都在中国大陆境内,这在理论上可以减少公共 DNS 服务器与 权威 DNS 服务器之间的通信时长。

结语:推石头的人

DNS 冷启动的问题,从未有完美的解决方案。它像是互联网架构中注定存在的一段“延迟的诗意”——每个访问者都从自己的网络拓扑出发,沿着看不见的路径,一步步推着那块属于自己的石头,直到抵达你的服务器山顶,换得屏幕上第一个像素的亮起。

对小型站点而言,这或许是命运的重量;但理解它、优化它、监测它,便是我们在这条漫长上坡路上,为石头磨出更光滑的棱角。

参见

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

2025-11-05 08:00:00

我最近一阵子在重构我的博客,恰巧之前一阵子准备秋招的时候背八股时看到了 HTTP/2 的服务端推送,于是便尝试在部署阶段为我的博客配置好 HTTP/2 的服务端推送,试图以此来进一步优化首屏加载速度。

HTTP/2 服务端推送为什么能提升首屏加载速度

如下图,在传统的 HTTP/1.1 中,浏览器会先下载 index.html 并完成第一轮解析,然后再从解析出的数据中拿到 css/js 资源的 url,再进行第二轮请求,在 tcp/tls 连接建立后最小需要两个 RTT 才能取回完整渲染页面所需的资源。

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 下载并解析后<br/>才能发起后续请求,增加往返延迟 (RTT)

而在 HTTP/2 的设想中,流程则是像下面这张图一样。当浏览器请求 index.html 时,服务端可以顺带将 css/js 资源一起推送给客户端,这样在 tcp/tls 连接建立后最小只需要一个 RTT 就可以将页面渲染所需的资源取回。

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: 浏览器收到资源前置推送<br/>减少请求轮次与首屏延迟

为了在 HTTP/1.1 中尽可能减少后续的请求,前端开发者尝试了非常多的优化手段,正如 Sukka 在《静态资源递送优化:HTTP/2 和 Server Push》一文中所讲:

关键资源、关键渲染路径、关键请求链的概念诞生已久,异步加载资源的概念可谓是老生常谈:懒加载图片、视频、iframe,乃至懒加载 CSS、JS、DOM,懒执行函数。但是,关键资源递送的思路却依然没有多少改变。

HTTP/2 的 Server Push 创造了新的资源递送思路,CSS/JS 等资源不用随着 html 一起递送也能在一个 RTT 内被传送到客户端,而这一部分资源可以被浏览器缓存起来,不被 html 那较短的 TTL 所限制。

初步方案

既然理清了 HTTP/2 服务端推送的优势,于是准备着手优化。我的博客是纯静态的,通过 DNS 进行境内外分流:境内流量会访问到 DMIT 一台带有 cmin2/9929 网络优化的 vps 上,通过 caddy 提供服务;境外流量则是直接打到 vercel,借助 Amazon 的 CDN 为全球网络提供边缘加速。网络架构大概是下面这个样子:

graph TD
    A[博客访客] --> B[发起DNS解析请求]
    B --> C[DNSPod 服务]
    C -->|境内访客:智能分流| D[网络优化 VPS]
    C -->|境外访客:智能分流| E[Vercel 平台]
    D --> F[Caddy]
    F --> G[返回博客内容给境内访客]
    E --> H[返回博客内容给境外访客]

Caddy 可以通过 http.handlers.push 模块实现 HTTP/2 的服务端推送,在 Caddyfile 中我们可以编写简单的推送逻辑,这没问题;vercel 平台则没有给开发者提供 HTTP/2 服务端推送的配置项,但好在我是静态博客,对平台依赖性不强,考虑迁移到 Cloudflare Workers,五年前就有开发者实现了

客户端的支持情况

历史上主流浏览器引擎(Chrome/Chromium、Firefox、Edge、Safari)曾普遍支持服务器推送技术。

2020 年 11 月,谷歌宣布计划在其 Chrome 浏览器的 HTTP/2 及 gQUIC(后发展为HTTP/3)实现中移除服务器推送功能

2022 年 10 月,谷歌宣布计划从Chrome浏览器中移除服务器推送功能,指出该扩展在实际应用中性能不佳、使用率低且存在更优替代方案。Chrome 106 成为首个默认禁用服务器推送的版本。

2024 年 10 月 29 日,Mozilla 发布了 Firefox 132版本,因“与多个网站存在兼容性问题”移除了对HTTP/2服务器推送功能的支持。

至此,主流浏览器对 HTTP/2 服务端推送(Server Push)的支持已全部终结。从最初被视为“减少往返延迟、优化首屏加载”的创新特性,到最终被全面弃用,HTTP/2 推送的生命周期不过短短数年,成为 Web 性能优化历史上的一次重要实验。

替代方案

1. HTTP 103 Early Hints

103 Early Hints 是对服务端推送最直接的“继任者”。它是一个信息性的 HTTP 状态码 (Informational Response),允许服务器在生成完整的 HTML 响应(例如,状态码为 200 OK)之前,先发送一个带有 Link 头部的“早期提示”响应。

这个 Link 头部可以告诉浏览器:“嘿,我还在准备主菜(HTML),但你可以先去把配菜(CSS、JS)准备好”。这样,浏览器就能利用服务器的“思考时间”提前开始下载关键资源或预热到所需源的连接,从而显著缩短首屏渲染时间。

与服务端推送的对比:

  • 决策权在客户端:Early Hints 只是“提示”,浏览器可以根据自身缓存情况、网络状况等因素决定是否采纳该提示。这就解决了服务端推送最大的痛点——服务器无法知晓客户端缓存而导致推送冗余资源。
  • 兼容性更好:它是一种更轻量、更易于中间代理服务器理解和传递的机制。

103 Early Hints 对动态博客很有意义,在后端进行计算之前先把需要的资源通过 103 响应告知浏览器,让浏览器先取回其他资源,再等待后端返回最终的 html;而对于我这种产物都是预构建好的静态博客,完全没有任何意义,网关有那个发 103 响应的闲工夫完全可以把 html 直接发过去了。

2. 资源提示(Resource Hints): Preload & Prefetch

早在服务端推送被弃用前,通过 <link>标签实现的资源提示就已经是前端性能优化的常用手段。它们将资源加载的提示声明在 HTML 中,由浏览器主导整个过程。

  • <link rel="preload">: 用于告诉浏览器当前页面必定会用到的资源,请以高优先级立即开始加载,但加载后不执行。比如,隐藏在 CSS 深处的字体文件或由 JS 动态加载的首屏图片。通过 Preload,可以确保这些关键资源能尽早被发现和下载,避免渲染阻塞。
  • <link rel="prefetch">: 用于告诉浏览器用户在未来可能访问的页面或用到的资源,请在浏览器空闲时以低优先级在后台下载。例如,在文章列表页 prefetch 用户最可能点击的文章页面的资源,从而实现近乎“秒开”的跳转体验。

Preload 和 Prefetch 将资源加载的控制权完全交给了开发者和浏览器,通过声明式的方式精细化管理资源加载的优先级和时机,是目前最成熟、应用最广泛的资源预加载方案,但仍然逃不过 2 RTT 的魔咒

尾声:写给一个理想主义者的挽歌

写到最后,我终究是没能为我的博客配上 HTTP/2 服务端推送。

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

在一个理想模型里,当浏览器请求 HTML 时,服务器顺手将渲染所需的 CSS 和 JS 一并推来,将原本至少两次的往返(RTT)干脆利落地压缩为一次。这是一个如此直接、如此漂亮的解决方案,几乎是前端工程师面对首屏渲染延迟问题时梦寐以求的“银弹”。它背后蕴含的是一种雄心勃勃的魄力:试图由服务端一次性地、彻底地解决“关键请求链”的延迟问题。

但 Web 的世界终究不是一个理想的实验室。它充满了缓存、重复访问的用户、以及形形色色的网络环境。

服务端推送最大的魅力,在于它的“主动”,而它最大的遗憾,也恰恰源于这份“主动”。它无法知晓浏览器缓存中是否早已静静躺着那个它正准备满腔热情推送的 style.css 文件。为了那一小部分首次访问用户的极致体验,却可能要以浪费更多再次访问用户的宝贵带宽为代价。

Web 的演进最终选择了一条更稳妥、更具协作精神的道路。它将决策权交还给了最了解情况的浏览器,整个交互从服务器的“我推送给你”,变成了服务器的“我建议你拿”,再由浏览器自己定夺。这或许不够浪漫,不够极致,但它更普适,也更健壮。

所以,我依然会怀念那个雄心勃勃的 Server Push。它代表了一种对极致性能的纯粹追求,一种美好的技术理想主义。尽管它已悄然淡出历史舞台,但它所指向的那个关于“速度”的梦想,早已被 103 Early Hints 和 preload 以一种更成熟、更懂得权衡的方式继承了下来。

参见

Nuxt Content v3 中数组字段的筛选困境与性能优化

2025-10-20 21:52:59

Nuxt Content 是 Nuxt 生态中用于处理 Markdown、YAML 等内容的强大模块。最近,我在使用 Nuxt v4 + Nuxt Content v3 重构博客(原为 Hexo)时,遇到了一个棘手的问题:v3 版本的默认查询 API 并未直接提供对数组字段进行“包含”($contains)操作的支持。

例如,这是我的正在写的这篇博客的 Front Matter:

---
title: Nuxt Content v3 中数组字段的筛选困境
date: 2025-10-20 21:52:59
sticky:
tags:
- Nuxt
- Nuxt Content
- JavaScript
---

我的目标是创建一个 Tag 页面,列出所有包含特定 Tag(例如 'Nuxt')的文章。

v2 的便捷与 v3 的限制

在 Nuxt Content v2 中,数据基于文件系统存储,查询方式是对文件内容的抽象,模拟了类似 MongoDB 的 JSON 文档查询语法。我们可以轻松地使用 $contains 方法获取所有包含 “Nuxt” 标签的文章:

const tag = decodeURIComponent(route.params.tag as string)

const articles = await queryContent('posts')
  .where({ tags: { $contains: tag } })  // ✅ v2 中的 MongoDB Style 查询
  .find()

但在使用 Nuxt Content v3 的 queryCollection API 时,我们很自然地会尝试使用 .where() 方法进行筛选:

const tag = decodeURIComponent(route.params.tag as string)

const { data } = await useAsyncData(`tag-${tag}`, () =>
    queryCollection('posts')
        .where(tag, 'in', 'tags')  // ❌ 这样会报错,因为第一次参数必须是字段名
        .order('date', 'DESC')
        .select('title', 'date', 'path', 'tags')
        .all()
)

遗憾的是,这样是行不通的。.where() 的方法签名要求字段名必须作为首个参数传入:where(field: keyof Collection | string, operator: SqlOperator, value?: unknown)

由于 Nuxt Content v3 底层采用 SQLite 作为本地数据库,所有查询都必须遵循类 SQL 语法。如果设计时未提供针对数组字段的内置操作符(例如 $contains 的 SQL 等价形式),最终的解决方案往往会显得比较“别扭”。

初版实现:牺牲性能的“全量拉取”

本着“尽快重构,后续优化”的思路,我写出了以下代码:

// 初版实现:全量拉取后使用 JS 筛选
const allPosts = (
    await useAsyncData(`tag-${route.params.tag}`, () =>
        queryCollection('posts')
            .order('date', 'DESC')
            .select('title', 'date', 'path', 'tags')
            .all()
    )
).data as Ref<Post[]>

const Posts = computed(() => {
    return allPosts.value.filter(post =>
        typeof post.tags?.map === 'function'
            ? post.tags?.includes(decodeURIComponent(route.params.tag as string))
            : false
    )
})

这种方法虽然满足了需求,但也带来了明显的性能代价:_payload.json 文件体积的膨胀。

在 Nuxt 项目中,_payload.json 用于存储 useAsyncData 的结果等动态数据。在全量拉取的方案下,每一个 Tag 页面 都会加载包含所有文章信息的 _payload.json,造成数据冗余。很多 Tag 页面仅需一两篇文章的数据,却被迫加载了全部文章信息,严重影响了性能。

tags 目录占据了 2.9MiB,是所有目录中最大的

_payload.json

讨巧方案:利用 SQLite 的存储特性进行优化

为了减少 useAsyncData 返回的查询结果,我查阅了 Nuxt Content 的 GitHub Discussions,发现在 v3.alpha.8 版本时就有人提出了一种“巧妙”的解决方案

由于 Nuxt Content v3 使用 SQLite 数据库,原本在 Front Matter 中定义的 tags 数组(通过 z.array() 定义)最终会以 JSON 字符串的形式存储在数据库中(具体格式可在 .nuxt/content/sql_dump.txt 文件中查看)。

sql_dump.txt

这意味着我们可以利用 SQLite 的字符串操作特性,通过 LIKE 动词配合通配符来完成数组包含的筛选,本质上是查询 JSON 字符串是否包含特定子串:

const tag = decodeURIComponent(route.params.tag as string)

const { data } = await useAsyncData(`tag-${route.params.tag}`, () =>
    queryCollection('posts')
        .where('tags', 'LIKE', `%"${tag}"%`)
        .order('date', 'DESC')
        .select('title', 'date', 'path', 'tags')
        .all()
)

下面是优化后重新生成的文件占用,体积减小还是非常显著的

  • tags 目录体积: 2.9MiB -> 1.4MiB
  • 单个 _payload.json 的体积: 23.1KiB -> 1.01 KiB

通过这种方法,我们成功将查询逻辑下推到了数据库层,避免了不必要的全量数据传输,显著降低了单个目录中 _payload.json 的体积,实现了性能优化。

tags 目录体积下降

_payload.json

参见

queryCollection - Nuxt Content

How do you query z.array() fields (e.g. tags) in the latest nuxt-content module (v3.alpha.8) · nuxt/content · Discussion #2955

后 OCSP 时代,浏览器如何应对证书吊销新挑战

2025-10-16 15:38:50

2023 年 8 月,CA/Browser Forum 通过了一项投票——不再强制要求 Let’s Encrypt 等公开信任的 CA 设立 OCSP Server

2024 年 7 月,Let's Encrypt 发布博客,披露其计划关闭 OCSP Server

同年 12 月,Let's Encrypt 发布其关闭 OCSP Server 的时间计划表,大致情况如下:

  • 2025 年 1 月 30 日 - Let’s Encrypt 不再接受新的包含 OCSP Must-Staple 扩展的证书签发请求,除非你的账号先前申请过此类证书
  • 2025 年 5 月 7 日 - Let's Encrypt 新签发的证书将加入 CRL URLs,不再包含 OCSP URLs,并且所有新的包含 OCSP Must-Staple 扩展的证书签发请求都将被拒绝
  • 2025 年 8 月 6 日 - Let's Encrypt 关闭 OCSP 服务器

Let's Encrypt 是全世界最大的免费 SSL 证书颁发机构,而这一举动标志着我们已逐渐步入后 OCSP 时代。

OCSP 的困境:性能与隐私的权衡

Let's Encrypt 这一举动的背后,是人们对 OCSP(在线证书状态协议)长久以来累积的不满。OCSP 作为一种实时查询证书有效性的方式,最初的设想很美好:当浏览器访问一个网站时,它可以向 CA(证书颁发机构) 的 OCSP 服务器发送一个简短的请求,询问该证书是否仍然有效。这似乎比下载一个巨大的 CRL(证书吊销列表) 要高效得多。

然而,OCSP 在实际应用中暴露出众多缺陷:

首先是性能问题。尽管单个请求很小,但当数百万用户同时访问网站时,OCSP 服务器需要处理海量的实时查询。这不仅给 CA 带来了巨大的服务器压力,也增加了用户访问网站的延迟。如果 OCSP 服务器响应缓慢甚至宕机,浏览器可能会因为无法确认证书状态而中断连接,或者为了用户体验而不得不“睁一只眼闭一只眼”,这都削弱了 OCSP 的安全性。

更严重的是隐私问题。每一次 OCSP 查询,都相当于向 CA 报告了用户的访问行为。这意味着 CA 能够知道某个用户在何时访问了哪个网站。虽然 OCSP 查询本身不包含个人身份信息,但将这些信息与 IP 地址等数据结合起来,CA 完全可以建立起用户的浏览习惯画像。对于重视隐私的用户和开发者来说,这种“无声的监视”是不可接受的。即使 CA 故意不保留这些信息,地区法律也可能强制 CA 收集这些信息。

再者,OCSP 还存在设计上的安全缺陷。由于担心连接超时影响用户体验,浏览器通常默认采用 soft-fail 机制:一旦无法连接 OCSP 服务器,便会选择放行而非阻断连接。攻击者恰恰可以利用这一点,通过阻断客户端与 OCSP 服务器之间的通信,使查询始终超时,从而轻松绕过证书状态验证。

OCSP 装订 (OCSP stapling)

基于上面这些缺陷,我们有了 OCSP 装订 (OCSP stapling) 方案,这在我去年的博客里讲过,欢迎回顾

强制 OCSP 装订 (OCSP Must-Staple)

OCSP Must-Staple 是一个在 ssl 证书申请时的拓展项,该扩展会告知浏览器:若在证书中识别到此扩展,则不得向证书颁发机构发送查询请求,而应在握手阶段获取装订式副本。若未能获得有效副本,浏览器应拒绝连接。

这项功能赋予了浏览器开发者 hard-fail 的勇气,但在 OCSP 淡出历史之前,Let's Encrypt 似乎是唯一支持这一拓展的主流 CA,并且这项功能并没有得到广泛使用。

~~本来不想介绍这项功能的(因为根本没人用),但考虑到这东西快入土了,还是给它在中文互联网中立个碑,~~更多信息参考 Let's Encrypt 的博客

Chromium 的方案:弱水三千只取一瓢

OCSP 的隐私和性能问题并非秘密,浏览器厂商们早就开始了各自的探索。2012 年,Chrome 默认禁用了 CRLs、OCSP 检查,转向自行设计的证书校验机制。

众所周知,吊销列表可以非常庞大。如果浏览器需要下载和解析一个完整的全球吊销列表,那将是一场性能灾难(Mozilla 团队在今年的博客中提到,从 3000 个活跃的 CRL 下载的文件大小将达到 300MB)。Chromium 团队通过分析历史数据发现,大多数被吊销的证书属于少数高风险类别,例如证书颁发机构(CA)本身被攻破、或者某些大型网站的证书被吊销。基于此洞察,CRLSets 采取了以下策略:

  1. 分层吊销:Chromium 不会下载所有被吊销的证书信息,而是由 Google 团队维护一个精简的、包含“最重要”吊销信息的列表。这个列表会定期更新并通过 Chrome 浏览器更新推送给用户。
  2. 精简高效:这个列表体积非常小,目前大概只有 600KB。它包含了那些一旦被滥用就会造成大规模安全事故的证书,例如 CA 的中间证书、或者一些知名网站(如 Google、Facebook)的证书。
  3. 牺牲部分安全性:这种方案的缺点也很明显——它无法覆盖所有的证书吊销情况。对于一个普通网站的证书被吊销,CRLSets 大概率无法检测到。根据 Mozilla 今年的博客所说,CRLSets 只包含了 1%~2% 的未过期的被吊销证书信息。

虽然 CRLSets 是一种“不完美”的解决方案,但它在性能和可用性之间找到了一个平衡点。它确保了用户在访问主流网站时的基础安全,同时避免了 OCSP 带来的性能和隐私开销。对于 Chromium 而言,与其追求一个在现实中难以完美实现的 OCSP 方案,不如集中精力解决最紧迫的安全威胁。

Firefox 的方案:从 CRLs 到 CRLite

与 Chromium 的“只取一瓢”策略不同,Firefox 的开发者们一直在寻找一种既能保证全面性,又能解决性能问题的方案。

为了解决这个问题,Mozilla 提出了一个创新的方案:CRLite。CRLite 的设计理念是通过哈希函数和布隆过滤器等数据结构,将庞大的证书吊销列表压缩成一个小巧、可下载且易于本地验证的格式

CRLite 的工作原理可以简单概括为:

  1. 数据压缩:CA 定期生成其全部吊销证书的列表。
  2. 服务器处理:Mozilla 的服务器会收集这些列表,并使用加密哈希函数和布隆过滤器等技术,将所有吊销证书的信息编码成一个非常紧凑的数据结构。
  3. 客户端验证:浏览器下载这个压缩文件,当访问网站时,只需本地对证书进行哈希计算,然后查询这个本地文件,就能快速判断该证书是否已被吊销。

与 CRLSets 相比,CRLite 的优势在于它能够实现对所有吊销证书的全面覆盖,同时保持极小的体积。更重要的是,它完全在本地完成验证,这意味着浏览器无需向任何第三方服务器发送请求,从而彻底解决了 OCSP 的隐私问题。

Firefox 当前的策略为每 12 小时对 CRLite 数据进行一次增量更新,每日的下载数据大约为 300KB;每 45 天进行一次全量的快照同步,下载数据约为 4MB。

Mozilla 开放了他们的数据看板,你可以在这里找到近期的 CRLite 数据大小:https://yardstick.mozilla.org/dashboard/snapshot/c1WZrxGkNxdm9oZp7xVvGUEFJCELfApN

自 2025 年 4 月 1 日发布的 Firefox Desktop 137 版本起,Firefox 开始逐步以 CRLite 替换 OCSP 校验;同年 8 月 19 日,Firefox Desktop 142 针对 DV 证书正式弃用 OCSP 检验。

CRLite 已经成为 Firefox 未来证书吊销验证的核心方案,它代表了对性能、隐私和安全性的全面追求。

后 OCSP 时代的展望

随着 Let's Encrypt 等主要 CA 关闭 OCSP 服务,OCSP 的时代正在加速落幕。我们可以看到,浏览器厂商们已经开始各自探索更高效、更安全的替代方案。

  • Chromium 凭借其 CRLSets 方案,在性能和关键安全保障之间取得了务实的平衡。
  • Firefox 则通过 CRLite 这一技术创新,试图在全面性、隐私和性能三者之间找到最佳的解决方案。

这些方案的共同点是:将证书吊销验证从实时在线查询(OCSP)转变为本地化验证,从而规避了 OCSP 固有的性能瓶颈和隐私风险。

未来,证书吊销的生态系统将不再依赖单一的、中心化的 OCSP 服务器。取而代之的是,一个更加多元、分布式和智能化的新时代正在到来。OCSP 这一技术可能逐渐被淘汰,但它所试图解决的“证书吊销”这一核心安全问题,将永远是浏览器和网络安全社区关注的重点。

参见