香港服务器的Linux系统中,如何设置并优化 Nginx 缓存规则,避免跨境电商大促时商品详情页频繁超时?

凌晨 2:40,香港葵涌机房墙上大屏的监控红得刺眼:商品详情页(/product/…)在“黑五预热”流量冲峰里开始出现 504,P99 响应一路抬头到 3.2s,应用组同事在群里刷着“DB 连接打满”“价格服务不回”“PHP-FPM backlog 飙升”。
我盯着一条 access log——/product/sku123,30 秒内被撞了两万次,全都直捣后端。那一刻我确定:这不是“代码快一点”的问题,而是该把“可缓存的动态页”尽量挡在 Nginx 层。
接下来的 6 小时,我把缓存结构、缓存 key 维度、微缓存、并发锁、stale 回源与观察指标一件件补上。当早上 8 点第一波更大流量进来的时候,图上那条 P99 线平稳在 180ms。
环境与基线
硬件与网络(香港机房落地)
| 项 | 规格 |
|---|---|
| 机房 | HK(多线 BGP,优先直连大陆,含 CMI/CT/CU 优化路由) |
| 服务器 | 2× Intel Xeon Silver 4210R,128GB RAM |
| 存储 | 2× NVMe SSD(Intel P4510 2TB)做 RAID1(mdadm),EXT4(noatime) |
| 网卡 | 2×10GbE(LACP) |
| OS | CentOS 7.9(内核 5.4 LTS,ELRepo) |
| Nginx | 1.24.x(Mainline),编译内置 http_slice,stream,geoip2 可选 |
| 应用 | PHP-FPM 7.4(池化 96 workers),Upstream 价格与库存微服务(gRPC/HTTP 混合) |
| 连接周边 | 前置海外 CDN(可选),主流终端来自大陆、东南亚与中东 |
说明:CentOS 7 已停更,我为了 BBR 与 io_uring 之类能力,切了 ELRepo 内核(5.4),系统稳定性更佳。若你是 Rocky/Alma 8/9,同样策略可用。
症状与基线数据(大促前一晚)
| 指标 | 峰值前 | 峰值时(问题态) |
|---|---|---|
| 详情页 QPS | 2.1k | 8.6k |
| P95 / P99 (ms) | 320 / 540 | 1,900 / 3,200 |
| 5xx 比例 | 0.1% | 4.7%(以 504 为主) |
| 上游(APP)CPU | 45% | 92% |
| MySQL 连接 | 680 / 2,000 | 2,000(满) |
| 价格服务超时 | 低 | 高(> 8%) |
根因:可缓存的详情页(SKU 不变)被当作“每次都得算”的动态请求。热点 SKU 遭遇“缓存踩踏”,后端被同一条 SKU 暴打。
设计目标与原则
- 热点 SKU 微缓存:把“秒级稳定性”交给 Nginx,TTLs 30–60s,为价格\库存“秒级一致但不是绝对强一致”的场景兜底。
- 防“缓存踩踏”:cache lock + stale-while-revalidate,只让一个请求去回源,其它直接吃旧缓存。
- 安全维度:缓存 key 必须隔离 SKU / 国家 / 币种 / 设备类型 / 语言。登录态、购物车、定制化必须绕过缓存。
- 回源友好:保持上游keepalive、降重重试、健康探测、熔断与限流策略。
- 可观测:日志里必须落地 $upstream_cache_status、cache 命中率、回源耗时等。
缓存策略设计(要点一图流)
- 微缓存(Microcaching):详情页 30–60s;异常状态 5–10s;404 10s。
- 并发锁:proxy_cache_lock on; + proxy_cache_background_update on;
- 旧缓存可用:proxy_cache_use_stale updating error timeout http_500 http_502 http_503 http_504;
- Key 维度:$scheme$host$request_uri + $geo_country + $currency + $device + $lang(示例用 map/header 生成)
- Cookie 绕过:session/cart/uid 等命中即 no_cache/bypass。
- 避免压缩碎片化:不让上游返回压缩体,由 Nginx 统一 gzip,减少“按 Accept-Encoding”产生的多份缓存。
- 回源头部:透传 X-Forwarded-For、X-Real-IP、X-Request-ID,便于上游定位。
- 容量与目录层次:NVMe 上 max_size 控制 + levels=1:2,禁用临时盘复制(use_temp_path=off)提升写入效率。
系统与文件系统调优(关键但容易忽略)
/etc/sysctl.d/99-nginx-tuning.conf
net.core.somaxconn = 4096
net.core.netdev_max_backlog = 250000
net.ipv4.ip_local_port_range = 10240 65000
net.ipv4.tcp_max_syn_backlog = 8192
net.ipv4.tcp_tw_reuse = 1
fs.file-max = 2097152
vm.swappiness = 1
vm.dirty_ratio = 10
vm.dirty_background_ratio = 5
limits 与挂载
# /etc/security/limits.d/nginx.conf
nginx soft nofile 1048576
nginx hard nofile 1048576
- EXT4 挂载加 noatime,nodiratime,discard(NVMe)
- RAID1 上把缓存分区单独做 LVM,便于扩容与迁移。
Nginx 核心配置(可直接套用)
下面以 proxy_cache(上游是 HTTP 应用)为例。如果你是 PHP-FPM,可替换为 fastcgi_cache,指令基本同构。
1)全局缓存区与日志
/etc/nginx/nginx.conf(节选)
user nginx;
worker_processes auto;
worker_rlimit_nofile 1048576;
events {
worker_connections 65535;
multi_accept on;
use epoll;
}
http {
include mime.types;
default_type application/octet-stream;
# 统一 gzip,由 Nginx 压缩,避免按 Accept-Encoding 形成多份缓存
gzip on;
gzip_vary on;
gzip_types text/plain text/css application/json application/javascript application/xml text/xml;
# 避免上游直接返回压缩体(减少缓存碎片)
proxy_set_header Accept-Encoding "";
# 缓存目录(NVMe 分区)
proxy_cache_path /data/nginx_cache/detail levels=1:2 keys_zone=DETAIL:512m
max_size=200g inactive=60m use_temp_path=off
loader_files=2000 loader_threshold=300;
# 访问日志带缓存命中信息
log_format main '$remote_addr - $request_id [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" "$http_user_agent" '
'rt=$request_time urt=$upstream_response_time ucs=$upstream_cache_status';
access_log /var/log/nginx/access.log main;
# 连接上游的通用头
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Request-ID $request_id;
# 地域/币种/设备/语言的归一(示例:来自边缘/CDN 的头或本地 map)
map $http_x_country $geo_country { default "HK"; CN "CN"; SG "SG"; AE "AE"; }
map $http_x_currency $currency { default "HKD"; CN "CNY"; SG "SGD"; }
map $http_user_agent $device { default "pc"; ~*"(iphone|android|mobile)" "m"; }
map $http_accept_language $lang { default "en"; ~*zh "zh"; ~*ar "ar"; }
# 是否命中“登录/购物车/预览”等需要绕过缓存的 Cookie
map $http_cookie $has_user_cookie {
default 0;
~*(session|uid|cart|token) 1;
}
# 请求参数绕过(例如预览、AB 实验)
map $arg_preview $is_preview { default 0; 1 1; }
map $arg_ab $is_ab { default 0; ~.+ 1; }
# 汇总成绕过标志
map "$has_user_cookie$is_preview$is_ab" $nocache {
default 0;
"100" 1; # 有用户态
"010" 1; # 预览
"001" 1; # AB
"110" 1; "101" 1; "011" 1; "111" 1;
}
upstream app_backend {
server 10.0.10.21:8080 max_fails=3 fail_timeout=10s;
server 10.0.10.22:8080 max_fails=3 fail_timeout=10s;
keepalive 256;
}
server {
listen 80 reuseport;
server_name www.example.hk;
# 产品详情页命中
location ~ ^/product/ {
proxy_cache DETAIL;
proxy_cache_key "$scheme://$host$request_uri|$geo_country|$currency|$device|$lang";
proxy_cache_valid 200 301 302 1m; # 正常页 1 分钟
proxy_cache_valid 404 10s; # 404 短暂缓存,降低打点
proxy_cache_valid 500 502 503 504 5s; # 异常短缓存,抑制雪崩
proxy_cache_lock on; # 防止并发回源
proxy_cache_lock_timeout 5s;
proxy_cache_min_uses 1;
proxy_cache_background_update on; # 后台刷新
proxy_cache_use_stale updating error timeout invalid_header http_500 http_502 http_503 http_504;
# 控制绕过/不缓存
proxy_no_cache $nocache;
proxy_cache_bypass $nocache;
# 避免个性化头污染缓存(谨慎!)
proxy_ignore_headers X-Accel-Expires;
add_header X-Cache-Status $upstream_cache_status always;
proxy_read_timeout 2s; # 上游慢就别拖
proxy_connect_timeout 1s;
proxy_send_timeout 2s;
proxy_pass http://app_backend;
}
# 不缓存的区域(例如购物车、下单)
location ^~ /cart/ { proxy_pass http://app_backend; }
location ^~ /order/ { proxy_pass http://app_backend; }
# 健康检查/监控
location /nginx_status {
stub_status on;
allow 127.0.0.1;
deny all;
}
}
}
要点
- 统一 gzip:让 Nginx 压缩,避免按 Accept-Encoding 形成多份缓存副本。
- Key 维度够用不泄漏:国家/币种/设备/语言是电商常见差异化因素(价格、模板、文案)。
- Lock + Background Update:只让一个请求打穿,其他读旧缓存。
- 短 TTL 的异常缓存:把临时高峰中的偶发 5xx 用 5s 缓住,抑制瞬时风暴。
- 谨慎 ignore headers:不要无脑忽略 Set-Cookie,本例是保证绕过条件成立才缓存,否则易“个性化内容泄露”。
2)PHP-FPM 场景(fastcgi_cache 简版示意)
如果详情页是 PHP 渲染:
fastcgi_cache_path /data/nginx_cache/detail levels=1:2 keys_zone=DETAIL:512m max_size=200g inactive=60m use_temp_path=off;
location ~ ^/product/ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
fastcgi_pass 127.0.0.1:9000;
fastcgi_cache DETAIL;
fastcgi_cache_key "$scheme://$host$request_uri|$geo_country|$currency|$device|$lang";
fastcgi_cache_valid 200 1m;
fastcgi_cache_valid 404 10s;
fastcgi_cache_valid 500 502 503 504 5s;
fastcgi_cache_lock on;
fastcgi_cache_lock_timeout 5s;
fastcgi_cache_use_stale updating error timeout invalid_header http_500 http_502 http_503 http_504;
fastcgi_no_cache $nocache;
fastcgi_cache_bypass $nocache;
add_header X-Cache-Status $upstream_cache_status always;
}
预热、失效与“不会翻车”的操作面
预热热点 SKU(简单可行)
# 热门SKU列表(根据近1小时 PV 排序)
cat hot_skus.txt | while read sku; do
curl -s -m 2 "http://www.example.hk/product/$sku" -H "X-Country: CN" -H "X-Currency: CNY" -H "Accept-Language: zh" >/dev/null &
done
wait
细粒度失效(没有第三方 purge 模块时)
- Key 加版本:在 proxy_cache_key 里附加 $arg_v,当商品价格变动时,发布系统给该 URL 带上 ?v=timestamp,实现自然穿透。
- CDN 配合:若前置 CDN,优先在 CDN 层做 Tag/Path purge,源站尽量少做“全盘清”。
- OpenResty 可选:若使用 OpenResty,可借 lua_shared_dict 维护 SKU→版本号映射,proxy_cache_key 引用变量,运维接口可原子更新版本号,命中即生效。
观测与告警
access log 示例
203.0.113.10 - 2f2a3c... [11/Sep/2025:02:58:10 +0800] "GET /product/sku123 HTTP/1.1" 200 45231 "-" "Mozilla/5.0 ..."
rt=0.182 urt=0.000 ucs=HIT
- ucs=HIT/BYPASS/MISS/EXPIRED/UPDATING 一眼看懂缓存状态
- Grafana/Prometheus 抓取:命中率、回源 RT、5xx 比例、NVMe IOPS/带宽、连接数
- 告警阈值:详情页 P99 > 500ms 持续 3 分钟,命中率 < 70%,上游 5xx > 1%
实测数据(优化前后对比)
| 指标 | 优化前 | 优化后(微缓存 60s + lock + stale) |
|---|---|---|
| 详情页 QPS(峰值) | 8.6k | 13.2k |
| 命中率(热点 2k SKU) | 18% | 86% |
| P95 / P99 (ms) | 1,900 / 3,200 | 120 / 180 |
| 5xx 比例 | 4.7% | 0.3% |
| 上游 CPU | 92% | 48% |
| MySQL 连接 | 2,000(满) | 900 左右 |
| 价格服务超时 | > 8% | < 1% |
解释:秒级微缓存+并发锁,把“热点 SKU 的重复回源”拦截在 Nginx;use_stale 避免上游偶发抖动时拉垮体验;上游减少无谓计算,腾出资源给真的动态请求(下单/支付)。
性能补丁包(不要忽略的小细节)
- NVMe 专属优化:use_temp_path=off + 独立分区;loader_files/threshold 提升冷启动扫描效率。
- 连接池:upstream keepalive 256,避免短连接抖动;proxy_http_version 1.1;(如需要)。
- 超时要短:proxy_read_timeout 2s,宁愿早放手让 stale 顶上,不要“拖慢全场”。
- HEAD/Range:详情页通常不用 range,若有大图/视频,拆静态域名+slice 模块单独缓存。
- BBR(可选):内核 5.4 开启 BBR,跨境长肥管道稳定不少。
- 时钟同步:NTP 必须硬;缓存 TTL 与日志时间对齐,否则排障极其折磨。
- 大连接突刺:reuseport 配合 SO_REUSEPORT,多 worker 更均衡。
踩坑记录(我真遇到过)
把 Set-Cookie 忽略错了:一开始我粗暴 proxy_ignore_headers Set-Cookie;,结果个性化推荐位被缓存,不同用户看到相同“最近浏览”。
修正:通过 $nocache 精确绕过,仅在“无用户态”时缓存。
Accept-Language 影响模板:没把 $lang 放入 key,导致英语用户看到中文模板。
修正:Key 加 $lang,或在渲染层避免模板差异。
CDN 与源站 TTL 打架:CDN 60s、源站 30s,出现“CDN 仍旧 MISS 并打爆源站”的奇怪波形。
修正:统一把 源站 TTL ≥ CDN TTL;热点路径让 CDN 更长、源站短些配 stale。
预热脚本没带国家/币种头:导致预热错面向 HK,而大量流量来自 CN,命中率上不去。
修正:预热脚本按主要区域维度触发。
一键检查清单(上线前 10 分钟自检)
- /product/ 命中 HIT?
- 登录后访问 HIT 是否消失(应 BYPASS)?
- 切换 X-Country/X-Currency,缓存 key 分片是否生效?
- 上游慢时,是否 stale 顶上且 P99 不抖?
- 热点 SKU 预热是否按主要地区/设备覆盖?
- NVMe 分区监控容量与 IOPS 是否在健康区间?
- 告警规则就绪:命中率、P99、5xx、NVMe、连接数。
附:完整示例配置(可直接落盘)
见上文 nginx.conf 片段;实际我会按“server 级 include”拆分到 /etc/nginx/conf.d/detail.conf,并把 map 放到 maps.conf,便于多人协作与回滚。
当天上午 8 点半,第二波流量比凌晨更大。大屏上 P99 仍然贴着 180ms 的水平线,我的手终于从键盘上松下来。
走出机房,电梯镜子里我像熬夜的熊猫——但心里是轻的:我们没有靠“祈祷”度过高峰,而是把可缓存的压力前置在 Nginx,让后端专注真正的动态。
后来几个大促,我们照着这套策略微调 TTL、完善“按 SKU 版本号”的失效,命中率稳定 80%+。每次看到那条稳定的延迟曲线,我都会想起那晚机房冷飕飕的风——以及屏幕上消失的 504。
TL;DR(可粘贴给同事的 30 秒摘要)
- 对商品详情页用“秒级微缓存”+ 并发锁 + stale 背景刷新;
- 缓存 key 必须包含 SKU/国家/币种/设备/语言,登录/购物车/预览绕过;
- 统一由 Nginx 压缩,避免 Accept-Encoding 导致多份缓存;
- 上游超时宁短不长,让 stale 顶上,避免拖慢全局;
- 观测必做:$upstream_cache_status 落日志、命中率/回源 RT/5xx 告警完善。