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=31536000Cache-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 / LFUTTL 优先或 FIFO
一致性容忍度允许秒级延迟(缓存异步更新)要求强一致或最终一致(需同步写库)
典型技术栈Redis + 本地内存缓存 + CDNRedis 作为缓冲队列 + 批量落盘

读多写少场景下的缓存同步代码(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 综合决策流程

离线缓存策略选择决策流程图 收到请求 资源类型? 静态资源 API 数据 读次极高(几乎不写) 策略:CDN + 强缓存 文件名 hash 自动失效 读写比例判断 读多写少:LRU + TTL 写多读少:写穿透 决策要点 静态资源 → 浏览器/CDN强缓存,hash版本控制。 API数据 → 读多写少用LRU+TTL;写多读少用写穿透/写回,避免缓存雪崩。

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 性能与稳定性优化

  1. Etags 和 Last-Modified 双保险:即使 Cache-Control: no-cache,条件请求也能减少带宽消耗。API 网关层自动生成 ETag(基于响应体 hash),减少后端计算压力。
  2. 静态资源版本回滚:保留至少两个历史版本目录,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
  1. 写多读少场景的缓存击穿防护:使用分布式锁或互斥队列,确保同一时间只有一个线程回源查询并填充缓存。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)
  1. 预热与过期时间打散:读多写少场景下,缓存预热先于流量高峰完成,减少穿透;同时将所有 TTL 加上随机偏移(±10%),避免雪崩。
场景决策清单:每次接入新接口,先回答以下三个问题:
① 资源是否跨用户共享?(共享→静态/公共CDN;私有→API缓存)
② 数据变更频率是多少?(<10次/分钟→读多写少;>100次/分钟→写多读少)
③ 能否容忍秒级不一致?(是→异步更新;否→同步写穿透)