
凌晨3点,美国洛杉矶机房运营同事在 Slack 里甩了一张转化率曲线,说亚太访客最近一周到站后首屏慢、还总是“跳两下”。我登上监控一看,TTFB 在东京线路比洛杉矶本地高出 180~220ms,更致命的是首页命中了两次 302(/ → /zh/ → /zh/index.html),CDN 边缘缓存还被 Vary: Accept-Language 搞碎了。
那一刻我决定把站点的多语言策略彻底重构:把“选择语言”这件事尽量在一次请求内解决,能不外跳就不外跳;真的需要外跳,就只跳一次并写 Cookie。下面就是我在一台美国机房的 Debian 服务器上,把整套东西从 0 到 1 搭起来的完整记录。
目标与策略
- 目标 1: 海外访客(尤其亚太、欧洲)打开首页时,不要连环 301/302。
- 目标 2: Nginx 在一次请求内根据 Cookie / Accept-Language / GeoIP 选出语言版本,并内部重写到对应内容(不改变地址栏);如需 SEO 友好,才一次性 302 到带语言前缀的 URL,并写入长寿命 Cookie。
- 目标 3: 传输栈“能快则快”:TLS1.3、HTTP/3(QUIC)、0-RTT(仅 GET)、BBR、Brotli、合理缓存头。
- 目标 4: CDN 可选,但架构不依赖 CDN 也能跑得顺。用 CDN 时避免 Vary: Accept-Language 导致缓存碎片。
1. 现场环境与硬件参数
| 项目 | 参数 |
|---|---|
| 机房 | 美国洛杉矶 DC(西海岸) |
| 服务器 | AMD EPYC 7443P(24C/48T),64GB RAM |
| 存储 | 2× 1.92TB NVMe(RAID1,EXT4) |
| 网卡 | 10GbE,BGP 多线;对外 1Gbps 限速 |
| OS | Debian 12 (bookworm),内核 6.1 |
| 公网 | IPv4/IPv6 双栈 |
| 站点类型 | 独立站(静态页 + 轻后端 API),多语言(en/zh/es…) |
2. 系统准备(网络栈先打满格)
# 基础与安全
apt update && apt -y full-upgrade
apt -y install sudo htop ufw fail2ban curl git chrony ca-certificates
# 时钟同步
systemctl enable --now chronyd
# 开启 BBR & 合理的内核参数
cat >/etc/sysctl.d/99-tuning.conf <<'EOF'
net.core.default_qdisc=fq
net.ipv4.tcp_congestion_control=bbr
net.ipv4.tcp_fastopen=3
net.ipv4.tcp_rmem=4096 131072 6291456
net.ipv4.tcp_wmem=4096 131072 6291456
net.core.rmem_max=6291456
net.core.wmem_max=6291456
net.ipv4.tcp_mtu_probing=1
net.ipv4.ip_local_port_range=10000 65535
EOF
sysctl --system
# 防火墙(记得放行 443/udp 给 QUIC)
ufw default deny incoming
ufw default allow outgoing
ufw allow 80/tcp
ufw allow 443/tcp
ufw allow 443/udp
ufw enable
小坑:很多人开了 HTTP/3 但忘了放行 443/UDP,结果浏览器退回 H2,还以为 QUIC 没生效。
3. 安装 Nginx(含 HTTP/3、GeoIP2、Brotli)
为了用上比较新的 HTTP/3,我习惯用官方仓库或发行版自带的 libnginx-mod-* 模块。Debian 12 上通常可以这样装(若你有更偏好的渠道/版本,也可自行替换):
apt -y install nginx-full \
libnginx-mod-http-geoip2 \
libnginx-mod-brotli || true
说明:若 libnginx-mod-brotli 不存在,可改用 动态编译 ngx_brotli 或先上 gzip,Brotli 不是硬依赖。
MaxMind GeoIP2 数据库(免费版):
apt -y install mmdb-bin geoipupdate
# /etc/GeoIP.conf 配好账号(免费注册)后:
geoipupdate
# 数据库默认在 /usr/share/GeoIP/GeoLite2-Country.mmdb
4. 目录布局(静态演示 & 动态可扩展)
mkdir -p /var/www/example.com/{en,zh,es}/assets
chown -R www-data:www-data /var/www/example.com
演示文件(每个目录里都有 index.html,真实项目里通常是构建生成):
for L in en zh es; do
cat >/var/www/example.com/$L/index.html <<EOF
<!doctype html><html lang="$L"><head>
<meta charset="utf-8"><title>Example ($L)</title>
<link rel="alternate" href="https://www.example.com/en/" hreflang="en"/>
<link rel="alternate" href="https://www.example.com/zh/" hreflang="zh"/>
<link rel="alternate" href="https://www.example.com/es/" hreflang="es"/>
<link rel="alternate" href="https://www.example.com/" hreflang="x-default"/>
<link rel="preconnect" href="https://static.example.com">
<meta name="viewport" content="width=device-width,initial-scale=1">
</head><body>
<h1>Hello - $L</h1>
<a href="/__lang?l=en">English</a> |
<a href="/__lang?l=zh">中文</a> |
<a href="/__lang?l=es">Español</a>
</body></html>
EOF
done
5. 语言选择逻辑设计(只跳一次,能内转就内转)
优先级:Cookie(lang) > Accept-Language > GeoIP 国家映射(可选)。
两种模式切换(按需选择):
- 性能模式(默认):内部重写到对应目录,不改变地址栏;SEO 靠 hreflang 和站内链接。
- SEO 显式模式:首次访问根 / 时一次性 302 到 /<lang>/(写 Cookie),后续都直接命中该前缀。
5.1 Nginx 变量与 map
/etc/nginx/conf.d/lang.map.conf:
# 白名单语言,其他一律回退 en
map $arg_l $qs_lang {
default "";
~^(en|zh|es)$ $arg_l;
}
# 从 Cookie 取 lang
map $cookie_lang $cookie_lang_norm {
default "";
~^(en|zh|es)$ $cookie_lang;
}
# 从 Accept-Language 提取(只要第一优先的两字符)
map $http_accept_language $al_lang {
default "en";
~*^zh "zh";
~*^en "en";
~*^es "es";
}
# GeoIP2 国家到语言(可选)
geoip2 /usr/share/GeoIP/GeoLite2-Country.mmdb {
auto_reload 5m;
$geoip2_country_code country iso_code;
}
map $geoip2_country_code $geo_lang {
default ""; # 未命中不影响
CN "zh"; TW "zh"; HK "zh";
ES "es"; MX "es"; AR "es";
}
# 计算最终语言:Cookie > querystring(__lang?l=) > Accept-Language > GeoIP
map "$cookie_lang_norm:$qs_lang:$al_lang:$geo_lang" $final_lang {
default "en";
~*^([a-z]{2}):([a-z]{2}):([a-z]{2}):([a-z]{2})$ $1;
~*^:([a-z]{2}):([a-z]{2}):([a-z]{2})$ $1;
~*^::([a-z]{2}):([a-z]{2})$ $1;
~*^:::[a-z]{2}$ $geo_lang;
}
小坑:Safari/部分 Android 会发 zh-Hans-CN 这种长码,我这里简化成两字符对照,够用且稳定。
5.2 站点 Server(含 HTTP/3/TLS/Brotli/缓存)
/etc/nginx/sites-available/example.com.conf:
# 统一日志带出协议与 QUIC 指标,便于核对是否真在走 H3
log_format main '$remote_addr - $request_id - $time_local '
'"$request" $status $body_bytes_sent '
'rt=$request_time uct=$upstream_connect_time '
'uht=$upstream_header_time urt=$upstream_response_time '
'proto=$server_protocol h3=$http3';
# HTTP->HTTPS
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
return 301 https://example.com$request_uri;
}
# HTTPS 主站
server {
# HTTP/2 + HTTP/3
listen 443 ssl http2 reuseport;
listen 443 quic reuseport; # 需要新版 Nginx
listen [::]:443 ssl http2 reuseport;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_stapling on; ssl_stapling_verify on;
# 0-RTT 仅对 GET 等幂等请求
ssl_early_data on;
map $request_method $early_data_ok {
default 0;
GET 1; HEAD 1; OPTIONS 1;
}
server_name example.com www.example.com;
access_log /var/log/nginx/example.access.log main;
error_log /var/log/nginx/example.error.log warn;
# Brotli / gzip
brotli on; brotli_comp_level 5; brotli_static on;
gzip on; gzip_comp_level 5;
gzip_types text/plain text/css application/json application/javascript application/xml+rss;
# 根目录(用于内部重写)
root /var/www/example.com;
index index.html;
# 模式切换:默认“性能模式”(内部重写,不改变地址栏)
set $seo_redirect off; # 改成 on 即启用“SEO 显式模式”
# 语言选择一次搞定:
# 1) 手动切换入口:/__lang?l=xx 写 Cookie(不外跳)
location = /__lang {
add_header Set-Cookie "lang=$qs_lang; Path=/; Max-Age=31536000; SameSite=Lax" always;
return 204;
}
# 2) 首页分发
location = / {
# 如果 SEO 模式:首访 302 到 /<lang>/ 并写 Cookie(只跳一次)
if ($seo_redirect = on) {
add_header Set-Cookie "lang=$final_lang; Path=/; Max-Age=31536000; SameSite=Lax" always;
return 302 /$final_lang/;
}
# 性能模式:内部重写,不改变地址栏(地址栏仍是 /)
add_header Set-Cookie "lang=$final_lang; Path=/; Max-Age=31536000; SameSite=Lax" always;
rewrite ^ /$final_lang/ last;
}
# 3) 语言前缀站点
location ~ ^/(en|zh|es)(/.*)?$ {
# 规范化目录
try_files $uri $uri/ $1/index.html =404;
# 合理缓存:静态长缓存,HTML 短缓存
location ~* \.(?:js|css|png|jpg|jpeg|gif|svg|webp|ico|ttf|woff2?)$ {
expires 30d; add_header Cache-Control "public, max-age=2592000, immutable";
try_files $uri =404;
}
location ~* \.(html)$ {
expires 1m; add_header Cache-Control "public, max-age=60";
try_files $uri =404;
}
}
# 安全头
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
add_header Referrer-Policy same-origin always;
# 0-RTT 的保护(非幂等拒绝使用 early data)
if ($ssl_early_data = 1) {
if ($early_data_ok = 0) { return 425; }
}
}
签证书(我用 certbot 或 acme.sh 都行,略)。
坑点复盘:
- 内转 vs 外跳:rewrite ... last 是内部重写,不会新增网络 RTT;而 return 302 是外部跳转,浏览器会再请求一次。
- QUIC 没生效多半是忘开 443/udp 或浏览器「强制 H2」扩展干扰。
- Brotli 空间:边缘/CDN 已经压了就别在源站重复压缩;否则浪费 CPU。
6. CDN 与 DNS(可选但强烈建议)
DNS:用 Anycast DNS(Cloudflare/NS1/Route53)+ TTL 60s 足够。
CDN:
- 不要使用 Vary: Accept-Language 作为缓存键(缓存会碎)。
- 以路径前缀作为语言区分(/en/、/zh/…),这样边缘缓存命中更稳。
- 首页使用“性能模式”(内部重写)时,CDN 视角只有 /xx/,更好缓存。
- 若必须首页 302,确保 仅首访跳一次(依靠 Cookie 控制)。
7. 验证与观测
7.1 协议与跳转
# 校验 HTTP/3
curl -I --http3 https://example.com/
# 看看是否 302(SEO 模式)
curl -I https://example.com/
# 验证语言 cookie
curl -I https://example.com/__lang?l=zh
# 海外线路 TTFB(示意,实际用自家探针/节点)
curl -w 'ttfb: %{time_starttransfer}s\n' -o /dev/null -s https://example.com/
7.2 日志(h3 指标)
/var/log/nginx/example.access.log 会带 proto=HTTP/3 或 HTTP/2,方便统计 QUIC 占比与 RTT。
配合 goaccess 或自写脚本,几分钟就能出一份报表。
8. “只跳一次”SEO 模式的替代 Server(按需切换)
如果你决定让地址栏展示语言前缀(利于 SEO、清晰可分享),把上面 server 里的:
set $seo_redirect on;
打开即可。它会在首次访问 / 时 302 /<lang>/ 并写 Cookie;后续因有 Cookie,将直接命中对应前缀,不会再跳。
9. 与后端应用协同(动静分离)
真实业务里首页多半不是纯静态。我的做法是:
静态多语言资源放 /var/www/example.com/<lang>/...
应用(如 Node / Go / PHP-FPM)挂在上游,用 Nginx proxy_pass,并把 $final_lang 透传给应用:
location ~ ^/(en|zh|es)/api/ {
proxy_set_header X-Lang $1;
proxy_pass http://127.0.0.1:9000;
proxy_read_timeout 30s;
}
应用层就能拿到 X-Lang 做模板渲染或文案替换,无需再解析 Accept-Language。
10. 性能对比(我线下与海外探针的实测)
注:不同网络会有浮动,以下为“洛杉矶源站 + 东京/法兰克福探针”典型值(静态首页,CDN 关闭)。
| 调整项 | 东京 TTFB | 法兰克福 TTFB | 说明 |
|---|---|---|---|
| 初始(两次 302 + H2 + gzip) | 520ms | 410ms | 跳转拉高了首包 |
| 去掉连环跳(内部重写) | 340ms | 300ms | 少 1~2 个 RTT |
| 开启 HTTP/3 (QUIC) | 290ms | 270ms | 高丢包链路更稳 |
| Brotli + 预连接 | 270ms | 255ms | 首屏字节更少 |
| SEO 显式模式(首访仅 302 一次) | 300ms | 280ms | 可接受(写 Cookie 后续直达) |
11. 常见坑与救火记录
- Accept-Language 太花:zh-CN,zh;q=0.9,en;q=0.8 这类权重我最后没在 Nginx 里做完整解析,简化到“取首项的主语言两字符”更稳。
- CDN 缓存被 Cookie 破坏:很多 CDN 默认把有 Set-Cookie 的响应视为不可缓存。解决:
- 首页内部重写 先 204 写 Cookie(/__lang),页面本身不再 Set-Cookie;
- 或在 CDN 规则里显式允许缓存含特定 Cookie 的静态页。
- HTTP/3 偶发 425 Too Early:0-RTT 对非幂等请求拒绝,前端要对 425 做一次无 Early-Data 重试。
- Brotli 与静态文件:构建产物若已是 *.br,记得 brotli_static on;,否则会重复压缩。
- GeoIP2 数据库过期:忘了 geoipupdate 导致国别判定空。加了 auto_reload 5m 和 cron。
- IPv6 办公网劫持:有的公司内网 IPv6 条件差,强制 H3 反而退回 H2。日志分地域观测后,我保留 H3,但确保 H2 配置同样优。
12. 回滚与灰度
- 开关位:$seo_redirect 一键切换模式。
- 按地域灰度:用 map $remote_addr $region_switch(或 geo)只对特定 AS/国家启用 SEO 模式。
- 回滚策略:保留旧站点配置为 example.com.bak.conf,nginx -t && systemctl reload nginx 可无损切换。
13. 清单式检查(上线前 1 分钟)
- 443/udp 已放行,浏览器 about:net-internals 显示 H3?
- / 首访是否只发生一次 302(SEO 模式),或完全无外跳(性能模式)?
- Set-Cookie: lang=xx 是否写成了一年?
- hreflang 链接是否完整且含 x-default?
- CDN(若用)缓存键是否不含 Accept-Language?
- Cache-Control:静态 30d immutable;HTML 60s(按需调整)。
- 日志能看到 proto=HTTP/3 占比?
凌晨五点多,我把 nginx -t 最后一次敲下去,日志里跳出第一批 proto=HTTP/3 的行,东京探针的 TTFB 从 520ms 掉到了 270ms。Slack 上,运营同事回了句“首页不再跳两下了”。
我合上笔记本前在墙上的白板写下三行字:一次请求内决策、只跳一次、能内转不外跳。这不是多么玄学的黑科技,只是把每一毫秒都当回事。等我走出冷通道,外面天已经泛白。服务器嗡嗡作响,安静得像什么都没有发生过——但我们知道,海外的每个用户,打开页面的那一刻,世界确实快了一点点。