2. 场景分析:静态资源缓存 vs API 数据缓存、读多写少 vs 写多读少
构建离线缓存体系的第一步不是选择工具或设置过期时间,而是识别请求的本质。静态资源(CSS/JS/图片)与API数据(用户信息、订单列表)在缓存策略上几乎完全对立;读写比例则决定了缓存是让系统更快还是更不稳定。本章从这两个维度出发,建立缓存场景判别的第一性原理,并给出可落地的配置、代码和调试方法。
2.1 静态资源缓存 vs API 数据缓存
两类资源在缓存位置、失效机制、安全约束上存在根本差异。下表从7个关键维度进行对比:
| 维度 | 静态资源(CSS/JS/字体/图片) | API 数据(JSON/XML/Protobuf) |
|---|---|---|
| 缓存主体 | 文件字节流 | 序列化后的数据结构 |
| 典型位置 | 浏览器 Cache、CDN 边缘节点 | 服务端内存缓存(Redis/Memcached)、反向代理缓存(Nginx) |
| 缓存时长 | 通常 7–365 天(配合版本号或 hash) | 秒到分钟级(受数据新鲜度约束) |
| 失效机制 | URL 指纹变更(文件名 hash) | 主动过期 / 事件驱动更新 / TTL 到期 |
| HTTP 缓存指令 | Cache-Control: public, max-age=31536000 | Cache-Control: private, no-cache 或 max-age=60 |
| 安全要求 | 通常不涉及用户身份 | 需校验用户权限,缓存常按用户 ID 分片 |
| 缓存粒度 | 完整文件 | 单个资源对象或集合 |
实战判断口诀:如果资源被不同用户无差别访问且很少变更 → 静态资源缓存;如果资源响应依赖请求参数或用户身份 → API 数据缓存。
Nginx 混合配置示例:
# 静态资源 —— 强缓存一年
location ~* \.(js|css|png|jpg|svg)$ {
root /var/www/static;
expires 365d;
add_header Cache-Control "public, immutable";
# 开启高压缩
gzip_static on;
}
# API 数据 —— 仅允许 Nginx 内部缓存 60 秒
location /api/ {
proxy_pass http://backend;
proxy_cache my_cache;
proxy_cache_valid 200 60s;
proxy_cache_key "$scheme$request_method$host$request_uri$cookie_userid";
add_header X-Cache-Status $upstream_cache_status;
# 不允许浏览器缓存
add_header Cache-Control "private, no-cache";
}
2.2 读多写少 vs 写多读少
读写比例直接影响缓存穿透风险、数据一致性和淘汰策略选择。
| 特征 | 读多写少(典型:内容型首页、商品详情) | 写多读少(典型:日志收集、实时计分) |
|---|---|---|
| 缓存命中率 | 极高(>95% 可期) | 低(写入后很快被覆盖) |
| 推荐策略 | 「缓存旁路」+ 预加载(缓存预热) | 「写穿透」或「写回」+ 合并写入 |
| 淘汰算法 | LRU / LFU | TTL 优先或 FIFO |
| 一致性容忍度 | 允许秒级延迟(缓存异步更新) | 要求强一致或最终一致(需同步写库) |
| 典型技术栈 | Redis + 本地内存缓存 + CDN | Redis 作为缓冲队列 + 批量落盘 |
读多写少场景下的缓存同步代码(Python + Redis):
def get_product(product_id):
cache_key = f"product:{product_id}"
# 尝试从缓存读取
data = redis.get(cache_key)
if data:
return deserialize(data)
# 缓存未命中,查数据库
product = db.query("SELECT * FROM products WHERE id = ?", product_id)
if product:
# 缓存回写,TTL = 300 秒
redis.setex(cache_key, 300, serialize(product))
return product
写多读少场景下的写穿透配置(Redis Sentinel + 异步批量):
# 1. 写入时同步更新缓存和数据库
def write_score(user_id, score):
redis.set(f"score:{user_id}", score)
db.execute("UPDATE scores SET value = ? WHERE user_id = ?", score, user_id)
# 2. 或者使用 Redis 列表暂存,定时批处理
def batch_write():
while True:
batch = []
while len(batch) < 100:
item = redis.lpop("score_pending")
if not item:
break
batch.append(item)
if batch:
db.executemany("INSERT INTO scores_bulk VALUES (?, ?)", batch)
time.sleep(1)
2.3 综合决策流程
2.4 常见错误与调试
- 混淆浏览器缓存与代理/服务端缓存:使用
Cache-Control: public且未设no-cache时,浏览器可能长期缓存 API 响应,导致用户看到过时数据。调试时用curl -I https://api.example.com/v1/profile观察Cache-Control和Age头部。 - 写多读少场景使用长 TTL:大量写入后缓存无法快速刷新,客户端读取的一直是旧值。正确做法:写入时同步更新缓存,或将 TTL 设为极短(<10s)并配合消息队列异步写库。
- CDN 缓存污染:静态资源版本号生成策略不严谨(如仅用时间戳未用 hash),导致不同版本混用。使用
webpack的contenthash或gulp-rev自动生成指纹。
调试命令示例(检测 API 缓存行为):
# 首次请求
curl -s -D - "http://localhost/api/user?id=1001" | head -n 20
# 关注响应头:Cache-Control, ETag, Last-Modified, X-Cache-Status
# 带 If-None-Match 条件请求
curl -s -D - -H "If-None-Match: \"abc123\"" "http://localhost/api/user?id=1001"
# 期望返回 304 Not Modified 且 body 为空
2.5 性能与稳定性优化
- Etags 和 Last-Modified 双保险:即使
Cache-Control: no-cache,条件请求也能减少带宽消耗。API 网关层自动生成 ETag(基于响应体 hash),减少后端计算压力。 - 静态资源版本回滚:保留至少两个历史版本目录,CDN 清除失败时可快速回退。部署脚本示例:
# 保留前一个版本
mv /var/www/static/v2 /var/www/static/v2_bak
cp -r ./dist /var/www/static/v3
ln -sfn /var/www/static/v3 /var/www/static/current
# Nginx 中 root 指向 current
- 写多读少场景的缓存击穿防护:使用分布式锁或互斥队列,确保同一时间只有一个线程回源查询并填充缓存。Redis 的
SETNX实现防击穿:
def get_data_with_mutex(key, ttl=5):
data = redis.get(key)
if data:
return data
# 尝试获取锁
lock_key = f"lock:{key}"
if redis.setnx(lock_key, 1, ex=2):
try:
data = db.query(...)
redis.setex(key, ttl, data)
return data
finally:
redis.delete(lock_key)
else:
# 等待并重试
time.sleep(0.1)
return get_data_with_mutex(key, ttl)
- 预热与过期时间打散:读多写少场景下,缓存预热先于流量高峰完成,减少穿透;同时将所有 TTL 加上随机偏移(±10%),避免雪崩。
场景决策清单:每次接入新接口,先回答以下三个问题:
① 资源是否跨用户共享?(共享→静态/公共CDN;私有→API缓存)
② 数据变更频率是多少?(<10次/分钟→读多写少;>100次/分钟→写多读少)
③ 能否容忍秒级不一致?(是→异步更新;否→同步写穿透)
① 资源是否跨用户共享?(共享→静态/公共CDN;私有→API缓存)
② 数据变更频率是多少?(<10次/分钟→读多写少;>100次/分钟→写多读少)
③ 能否容忍秒级不一致?(是→异步更新;否→同步写穿透)