3. 缓存策略:从浏览器到Redis,层层加速

接口延迟优化,缓存是性价比最高的手段。

我个人习惯,碰到慢接口先不急着动代码,而是问一句:「这数据能不能缓存?」能的话,延迟往往能降一个数量级。

但缓存不是银弹。用错了地方,反而引入数据不一致、内存暴涨、雪崩等新问题。今天我把缓存策略拆成四个层次来讲:浏览器缓存、服务端缓存、Redis缓存,以及最关键的缓存失效策略。

缓存分层体系结构图 缓存分层体系(从用户请求到数据源) 用户请求 第1层:浏览器缓存 强缓存 / 协商缓存(Cache-Control, ETag) 第2层:服务端本地缓存(HTTP代理/进程内缓存) Nginx缓存 / 内存缓存(LRU, TTL) 第3层:Redis 分布式缓存 穿透/雪崩/击穿防护 + 数据持久化 数据库 / 外部服务 故障模式(旁路) 缓存穿透 缓存雪崩 缓存击穿

3.1 浏览器缓存:让用户自己帮我们分担压力

浏览器缓存是最靠近用户的一层。说白了,就是让用户的浏览器自己记住之前拿到的资源,下次再请求时直接拿出来用,压根儿不用到服务器。

强缓存 vs 协商缓存,这两个概念必须分清楚:

类型 响应头 行为 状态码
强缓存 Cache-Control: max-age=3600 时间到期前,浏览器直接从本地读取,不请求服务端 200 (from disk cache)
协商缓存 ETag / Last-Modified 每次请求都带着标记去服务端验证,资源没变返回304,浏览器用本地缓存 304 Not Modified

我的经验:静态资源(图片、CSS、JS)推荐用强缓存 + 文件名哈希。max-age 可以设到一年。为什么?因为只要文件名变了,浏览器就会当新资源请求。我在某个电商项目里,首页HTML设了30秒强缓存,结果用户反馈改了价格页面不更新——从那以后,HTML页面我坚决不用强缓存,只走协商缓存。

代码配置示例(Nginx):

# 静态资源:强缓存 + 文件名哈希
location /static/ {
    expires 365d;
    add_header Cache-Control "public, immutable";
}

# HTML页面:只走协商缓存
location / {
    etag on;
    if_modified_since before;
}

3.2 服务端缓存:别让每一层都去查数据库

浏览器这层没命中,请求到了我们的服务端。这里还有两个常见的缓存入口:

  • HTTP 代理缓存:Nginx 的 proxy_cache。适合接口返回结果不频繁变动的场景。我记得有一次,某报表接口每次查询耗时3秒,开 proxy_cache 后降到10毫秒,整个页面加载速度提升显著。
  • 进程内缓存:比如 Caffeine (Java), 或者 Go 里的 sync.Map。访问速度极快(微秒级),但只在本进程内有效,多实例部署时注意数据一致性问题。

注意:服务端缓存的 TTL 不宜设太长。我个人习惯把动态接口的不变窗口控制在 5 ~ 60 秒之间。太长的话用户会抱怨数据不实时,太短又起不到加速效果。

3.3 Redis 缓存:分布式场景下的加速核心

进程内缓存存不了多少东西,而且多实例之间不同步。这时候 Redis 就派上用场了。

Redis 缓存是现阶段最流行的选择。速度快(内存操作,微秒级)、数据结构丰富(String、Hash、List、Set、Sorted Set),还支持持久化和集群。

常用的 Redis 缓存模式有两种:

缓存模式 读策略 写策略 适用场景
Cache-Aside 先查缓存,命中直接返回;不命中查数据库,回写到缓存 先更新数据库,再删除缓存(或更新缓存) 常规业务接口,读多写少
Read-Through / Write-Through 缓存层代理数据库,应用只跟缓存交互 写操作由缓存层同步写入数据库 需要自动同步,减少代码侵入

避坑指南:写操作时,我曾经直接更新缓存值而不是删除缓存,结果并发场景下出现了数据不一致。后来的做法是:先更新数据库,再删除缓存。下次读请求发现缓存里没有,自然去数据库查最新的,再回写到缓存。这叫“延迟双删”,如果你也面临高并发写,可以考虑加上这个步骤。

Redis 客户端连接池配置很重要,别忽略:

# 以 Java 的 JedisPoolConfig 为例
config.setMaxTotal(50);          # 最大连接数
config.setMaxIdle(20);          # 最大空闲连接
config.setMinIdle(5);           # 最小空闲连接
config.setMaxWaitMillis(3000);  # 获取连接超时
config.setTestOnBorrow(true);   # 借出前验证连接是否可用

3.4 缓存失效策略:该更新时别犹豫,该淘汰时别手软

缓存不是永不过期。数据总会有变化,内存也总有上限。失效策略决定了缓存系统的稳定性和一致性。

3.4.1 TTL(过期时间)策略

最简单也最常用。给每个 key 设置一个过期时间,时间到了自动删除。

  • 固定 TTL:统一到期时间,简单但容易造成缓存雪崩(大量 key 同时过期)。
  • 随机 TTL:在基础过期时间上加上一个随机偏移量。比如基础 3600 秒,实际过期时间在 3600 ~ 4200 秒之间随机。我在直播礼物榜单接口上用过这个策略,一下子就把数据库压力降下来了。
// 随机 TTL 示例(伪代码)
long baseTtl = 3600;
long randomOffset = new Random().nextInt(600); // 0~600 秒随机
long actualTtl = baseTtl + randomOffset;
redis.setex(key, actualTtl, value);

3.4.2 内存淘汰策略(Redis 配置)

即使 TTL 设得再合理,内存满了怎么办?Redis 提供了 6 种淘汰策略:

策略 含义 建议场景
noeviction 不淘汰,写操作直接报错 不推荐线上使用,除非你的内存比数据量大得多
allkeys-lru 在所有 key 中淘汰最近最少使用的 通用场景,我的首选
volatile-lru 只在设了过期时间的 key 中淘汰最近最少使用的 适合缓存数据 + 持久数据混存的场景
allkeys-lfu 在所有 key 中淘汰最不经常使用的 访问频率差异极大的数据
volatile-ttl 淘汰即将过期的 key 不太推荐,效果不如 LRU

我曾经历过一次严重故障:线上 Redis 实例配置了 volatile-lru,但业务写入大量不带过期时间的 key,导致缓存空间被长期占满,带过期时间的 key 反而被大量淘汰,请求穿透到数据库……系统直接雪崩。后来我把策略改为 allkeys-lru,并加上了监控告警。我建议你也检查一下自己的 Redis 配置。

3.4.3 主动失效 vs 被动失效

被动失效靠 Redis 自己扫描和 TTL 到期删除,优点是省事,缺点是不及时。主动失效是在业务代码里主动删除或更新缓存。我个人在写操作后都会立刻触发主动失效,保证读请求快速拿到最新数据。

3.5 总结:缓存策略不是单点,而是一套组合拳

光说理论没用。我画了一张图放在本章头上,你可以看看完整的数据流。从浏览器缓存到服务端缓存,再到 Redis 缓存,每一层都承担不同的责任。把它们串联起来,才能让接口响应延迟降到最低。

最后送你一句话:“永远不要只依赖一层缓存。” 多层缓存 + 合理的失效策略 + 监控告警,才是真正的安心之选。