前几天新版活动上线,刚切流 10%,后台报警就直接炸了:多台动态渲染服务器的 CPU 负载直接顶到了 100%。与此同时,前端用户群里也炸开了锅,吐槽页面加载慢得像蜗牛,字体一闪一闪的,页面上的文字在大字号和小字号之间来回横跳,整个布局抖动(CLS)得让人眼睛疼。

我们原先为了让页面能展示极具设计感的艺术字体,又不想让用户下载 15MB 那么大的完整中文字体包,于是脑洞大开地搞了一个“动态字体裁剪服务”:当用户请求页面时,后端根据页面上的文本内容,动态调用 Python 的 pyftsubset 命令把字体切片后返回。

结果可想而知,高并发流量一冲,服务器直接被这种高强度的子进程调用和 CPU 密集型任务给干趴下了。

为了彻底查清后台的 CPU 都被谁给吞了,我们直接连上测试机,操起 eBPF 性能分析工具集(bcc-tools)和 perf 开始排查。

我们首先用 eBPF 的 execsnoop 追踪了系统调用,看看是不是有高频的子进程创建:

# 使用 eBPF bcc-tools 中的 execsnoop 追踪子进程调用情况
sudo execsnoop-bpfcc -x -T

# 输出结果非常辣眼睛:
# TIME     PCOMM            PID    PPID   RET ARGS
# 10:24:12 node             23412  23400    0 /usr/bin/node server.js
# 10:24:13 python3          23415  23412    0 /usr/local/bin/pyftsubset fonts/source.ttf --text=热烈欢迎新用户...
# 10:24:14 python3          23419  23412    0 /usr/local/bin/pyftsubset fonts/source.ttf --text=限时秒杀活动...

果然,Node 进程在不断地通过 child_process.exec 去调用 pyftsubset,每次调用都产生一个全新的 Python 虚拟机进程。

接着我们使用 perf 对 CPU 采样 10 秒,并绘制了火焰图:

# 采样 CPU 周期,并记录调用栈
sudo perf record -F 99 -p $(pgrep -d ',' node) -g -- sleep 10

# 解析采样数据并生成报告
sudo perf report -n --stdio

在生成的火焰图里,绝大多数的 CPU 时间片都消耗在了 Python 字体的解析、提取和重构上。这种“动态实时裁剪”简直就是后台性能的噩梦。

找到了病灶,解决思路就很清晰了:必须彻底废弃动态运行时裁剪,改在静态构建阶段用静态路由完成裁剪,并通过前端 CSS 优化彻底干脆地解决字体加载时的抖动问题。

首先,我们在前端静态构建阶段,通过 Node 脚本将字体按常用 3500 字以及 ASCII 字符集进行一次性打包裁剪,生成体积仅为 400KB 的 .woff2 格式字体包:

# 使用 pyftsubset 离线裁剪字体,只保留常用字以大幅压缩体积
pyftsubset fonts/source.ttf \
  --text-file=scripts/3500-common-chars.txt \
  --layout-features='*' \
  --flavor=woff2 \
  --output-file=public/fonts/subsetted-font.woff2

然而,即便缩减到了 400KB,在弱网环境下,字体加载依然存在延迟。如果直接使用默认的 font-display: swap,浏览器在加载完 Web 字体前会用系统默认字体代替,加载完后再瞬间替换。这就会导致默认字体的字形高度、宽度与 Web 字体不匹配,从而造成页面的大抖动(CLS 恶化)。

为了消除这种非专业的视觉抖动,我们利用了现代 CSS 的 @font-face 属性中的 size-adjustascent-overridedescent-override,对 fallback 字体进行了精确缩放,使其空间占比与即将载入的 Web 字体完美一致:

/* 引入裁剪好的 Web 字体 */
@font-face {
  font-family: 'CustomBrandFont';
  src: url('/fonts/subsetted-font.woff2') format('woff2');
  font-display: swap;
}

/* 声明一个匹配的 Fallback 字体,消除 CLS 抖动 */
@font-face {
  font-family: 'CustomBrandFont-Fallback';
  src: local('Arial');
  /* 缩放系统默认字体的字形尺寸,使其与 CustomBrandFont 的渲染尺寸一致 */
  size-adjust: 91.5%;
  ascent-override: 93%;
  descent-override: 18%;
  line-gap-override: 0%;
}

body {
  font-family: 'CustomBrandFont', 'CustomBrandFont-Fallback', sans-serif;
}

这里 size-adjust: 91.5% 是我们对比两个字体在 Canvas 下的字宽后精确算出来的。这样一来,无论 Web 字体加载是慢是快,页面文字在替换时的视觉大抖动彻底消失了,体验如丝般顺滑。

折腾完这一整套,服务器 CPU 负载直接从 100% 降到了个位数,接口响应延迟缩短了 98%。不要在追求视觉效果的同时忽视了最基础的构建性能和前端 CLS 性能,性能与体验其实可以两全。

大家在处理大中文字体加载时,是使用什么手段?是做静态字符集切片,还是直接用更现代的 Variable Fonts(可变字体)去优化渲染?