多语言外贸独立站的缓存命中率通常低于单语言站,原因分三类:条目基数变大、小语种流量稀疏、缓存键被切碎。第一类是正常现象,第二类由流量结构决定,第三类才是配置问题。先分清属于哪一类,再动手改规则。
一、命中率是怎么被算出来的
CDN 判断命中靠缓存键。键由源站地址、路径、查询串和参与 Vary 的请求头组成,其中任何一项变化,缓存都会当成一份新条目。命中率的定义很简单,命中次数除以命中次数与回源次数之和。由此可以推出一个常被忽略的结论:条目数量本身不影响命中率,影响它的是每个条目在过期时间窗口内被请求了几次。
语言版本进入缓存键的方式,直接决定条目翻几倍。这几种方式多数在独立站搭建阶段就已经定型,事后改要动地址结构,成本比一开始定对高得多。
| 维度 | 常见来源 | 条目影响 | 是否可避免 |
|---|---|---|---|
| 路径前缀 | /en/、/de/ 这类语言目录 | 按语种数线性增长 | 不可避免,属于设计本身 |
| 主机名 | en.example.com 这类子域 | 键天然隔离,不额外分裂 | 不可避免 |
| 查询串 | ?lang=en、utm 参数、跟踪参数 | 参数组合各建一份 | 可通过白名单避免 |
| Vary 请求头 | Accept-Language、Cookie | 按请求头取值分裂,组合数不可控 | 可通过边缘归一化避免 |
二、成因一:语言在源站用 Accept-Language 协商
现象很直接:HTML 响应头里带着 Vary: Accept-Language。客户端发来的这个头是连续取值,en-US,en;q=0.9 和 en-GB,en;q=0.9 会被当成两份条目,几十种取值就能把同一份内容的缓存打散。判断方法是给同一个地址发两个不同的语言头,对比响应头。
curl -sI -H "Accept-Language: en-US,en;q=0.9" "https://www.example.com/product/1001"
curl -sI -H "Accept-Language: de-DE,de;q=0.9" "https://www.example.com/product/1001"
两条命令返回的 Vary 里若出现 Accept-Language,说明缓存要按这个头分裂。处置方式是把协商挪到边缘层:边缘读一次语言,然后 302 到带语言前缀的规范地址,源站不再依赖请求头判断语言,Vary 随后就能从 HTML 响应里去掉。语言选择需要记住时,用带语言前缀的地址兜住,别用 Cookie。
三、成因二:同一个页面存在多个地址形态
大小写不一致、尾斜杠有无、语言前缀与查询参数并存,这几种情况经常同时出现在一个站上,结果是同一份内容存了好几份。归一化要在边缘或 Nginx 层统一处理,把变体 301 到规范地址。
server {
listen 443 ssl;
if ($request_uri ~ "^/[A-Z]{2}/") {
rewrite ^/([A-Z]{2})(/.*)$ /$1$2 redirect;
}
rewrite ^/([a-z]{2}(?:-[a-z]{2})?)$ /$1/ permanent;
location / {
proxy_cache_bypass $arg_no_cache;
proxy_cache_key "$scheme$host$request_uri";
}
}
查询串的白名单同理。只把真正改变页面内容的参数放进缓存键,跟踪类参数一律忽略,否则每个渠道链接都会生成独立条目。
四、成因三:小语种流量稀疏,比例天然偏低
这一类不是配置问题。假设一个条目设置了 3600 秒的过期时间,德语页面在这个窗口里被访问了 40 次,波兰语页面只被访问了 2 次,那么波兰语条目大部分时间处在过期状态,来一次回源一次。条目越多、稀疏语种的占比越高,全站命中率读数就越低,而配置本身没错。
判断方法是在日志里按语言分组,算每个语种在过期时间窗口内的平均请求数。某个语种的平均请求数长期小于 1,说明它在这个窗口里注定命中不了,要么把它的过期时间放长,要么把它的页面改成静态预生成,用构建时产出替代运行时渲染。给稀疏语种设短过期时间是最常见的反向操作。
五、成因四:HTML 里混进了个性化片段
现象是响应头里同时出现 Set-Cookie 与 Cache-Control: private,或者命中状态长期停在 BYPASS 与 DYNAMIC。原因是币种、税费、登录状态、库存被直接渲染进了 HTML。只要这些片段和公共内容同一份响应,整页就没法被共享缓存。
处置方向是分层:HTML 层只承载公共内容,个性化部分走客户端小接口或边缘包含。这一步做完,HTML 的缓存状态才能从 private 变成 public。
六、成因五:清理粒度太粗
每次上新或改价就全站清一遍,命中率会呈锯齿状。做法是给缓存对象打标签,按语种和内容组定向清理。以支持标签清理的 CDN 为例,发布时只清受影响的语种与内容组。
curl -X POST "https://api.example-cdn.com/purge" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tags\":[\"lang-en\",\"group-products\"]}"
标签的命名建议在项目初期就定下来,语言一维、内容组一维,两维组合足够覆盖绝大多数发布场景。
七、排查脚本:按语言分组算命中率
下面这段脚本只依赖标准库,把 CDN 或 Nginx 的访问日志按语言前缀分组,输出每个语种的命中率与未命中最多的路径。字段序号用参数传入,适配不同的日志格式。
import argparse
import collections
import re
LANG = re.compile(r"^/([a-z]{2})(?:-[a-z]{2})?/")
HIT = {"HIT", "HIT-STALE", "TCP_HIT"}
def main():
ap = argparse.ArgumentParser(description="按语言统计缓存命中率")
ap.add_argument("log", help="访问日志路径")
ap.add_argument("--uri", type=int, default=6, help="URI 所在字段序号,从 0 起")
ap.add_argument("--cache", type=int, default=9, help="缓存状态字段序号,从 0 起")
ap.add_argument("--top", type=int, default=20, help="未命中路径输出条数")
args = ap.parse_args()
total = collections.Counter()
hit = collections.Counter()
miss = collections.Counter()
with open(args.log, "r", encoding="utf-8", errors="ignore") as fh:
for line in fh:
parts = line.split()
if len(parts) <= max(args.uri, args.cache):
continue
uri = parts[args.uri]
status = parts[args.cache].upper()
m = LANG.match(uri)
lang = m.group(1) if m else "no-lang"
total[lang] += 1
if status in HIT:
hit[lang] += 1
else:
miss[lang + " " + uri.split("?")[0]] += 1
print("lang hit_rate requests")
for lang, n in total.most_common():
print("%-9s %8.1f%% %8d" % (lang, 100.0 * hit[lang] / n, n))
print("")
print("top miss paths:")
for path, n in miss.most_common(args.top):
print("%8d %s" % (n, path))
if __name__ == "__main__":
main()
跑法示例,按实际日志调整两个字段序号。
python cache_hit_by_lang.py access.log --uri 6 --cache 9 --top 30
八、症状与处置对照
| 症状 | 判定依据 | 处置方向 |
|---|---|---|
| 命中率低但各语种均匀 | 日志里未命中主要是 EXPIRED | 延长过期时间,补预热 |
| 某几个语种明显拖低均值 | 该语种窗口内平均请求数不足 1 | 静态预生成,长过期时间 |
| 未命中集中在 BYPASS 与 DYNAMIC | HTML 响应头含 private 或 Set-Cookie | 剥离个性化片段,改成小接口 |
| 同一路径反复出现不同键 | 地址变体、查询串进入缓存键 | 地址归一化,查询串白名单 |
| 命中率呈锯齿波动 | 与发布时间点吻合 | 按标签定向清理 |
九、关于命中率数字的口径
公开文章里的命中率数字要谨慎使用。我核过一批流传较广的说法,查下来基本都落在建站服务商或内容站的推广页上,署名机构层层叠加,原始报告找不到发布主体。这类数字不能当行业基准,正确做法有两个:一是用上面那段脚本按语种算出自己站的真实分布,二是只做同语种改造前后的纵向对比,改造前的读数就是自己的基准。
多语言结构规范化的一个附带好处,是缓存规则可以按语言前缀批量套用。产品资料如果只有一份来源,各语言版本的页面结构天然一致,生成和运维成本都会下降,独立站SEO 里最容易出错的多语言版本配对也更容易保持稳定。这也是 AI 建站平台这类工具当前主要解决的环节。NeoGress 的做法就是把同一份产品资料生成成多语言页面。需要说明的是,结构一致不等于命中率一定高,键规则、过期时间、清理策略仍要按自己站的情况配一遍,工具替代不了这一步。
十、上线前自查清单
-
HTML 响应头里不再出现 Vary: Accept-Language。
-
语言前缀、大小写、尾斜杠三种变体已 301 到规范地址。
-
查询串白名单已配置,跟踪参数不进缓存键。
-
HTML 响应头里不再出现 Set-Cookie 与 Cache-Control: private。
-
按语言前缀打了缓存标签,发布走定向清理。
-
稀疏语种已改成静态预生成或长过期时间,并补了预热。
-
已用脚本按语种拉过一次命中率,作为改造前的基准读数。
你手上这个站的命中率,是按全站均值看的,还是按语种分开看的?如果只看了均值,可以先跑一遍脚本,多半能发现拖低平均值的其实是两三个语种。评论区说说你的读数,或者你踩过的键规则坑。