如何使用缓存优化系统性能?

一、本地缓存(浏览器端)

平时使用 Fiddler 或浏览器 DevTools 时,我们经常会看到一些接口返回 304 状态码 + Not Modified

5ae757f7c5b12901d4422b5722c0647b.webp

如果不了解前端缓存技术,很容易对此感到困惑。浏览器的本地缓存主要分为两种机制:协商缓存强缓存

1.1 协商缓存

协商缓存,顾名思义就是与服务端协商之后,通过协商结果来判断是否使用本地缓存。

实现方式有两种,原理相同:

方案 请求头 响应头 判断依据
基于时间 If-Modified-Since Last-Modified 文件最后修改时间
基于唯一标识 If-None-Match ETag 内容哈希值

推荐 ETag 方案——它可以更准确地判断文件内容是否被修改,避免时间篡改导致的不可靠问题。

完整流程(以 ETag 为例):

  1. 首次请求:服务器返回资源的同时,在 Response 头部加上 ETag 唯一标识(根据资源内容生成)
  2. 再次请求:浏览器在 Request 头部带上 If-None-Match: <上次ETag>
  3. 服务端比对
    • 值相等 → 返回 304 Not Modified(浏览器从本地缓存加载)
    • 值不相等 → 返回 200 OK + 新资源 + 新 ETag

1.2 强缓存

强缓存指的是只要判断缓存没有过期,则直接使用浏览器的本地缓存。

如下图所示,返回的是 200 状态码,但在 size 项中标识的是 memory cache

0a169df1141f31326b4b6ab331ab3748.webp

强缓存通过两个 HTTP Response Header 实现:

Header 类型 说明
Expires 绝对时间 具体的过期时间点
Cache-Control 相对时间 过期时长(如 max-age=3600

⚠️ 建议使用 Cache-Control。基于 Expires 的强缓存会因为客户端/服务端时间不一致导致缓存管理问题。

流程:

  1. 首次请求:Response 头部带上 Cache-Control: max-age=3600
  2. 再次请求:浏览器计算资源是否过期 —— 未过期直接用缓存,已过期才向服务器请求
  3. 服务端响应:更新 Cache-Control 时间

二、缓存网关(CDN)

除了浏览器本地缓存,还可以在网关层设置缓存——也就是熟悉的 CDN(内容分发网络)

CDN 通过不同地理位置的缓存节点存储资源副本,当用户访问时由最近的节点返回资源,大幅降低延迟。这种方式常用于视频、图片等静态资源的加速。

三、服务层缓存技术

前端缓存主要用于不常修改的常量数据和静态资源文件,而大部分接口数据的缓存都在服务端进行统一管理。

缓存的初衷

  • 数据库并发查询压力过大 → 用缓存减轻数据库压力
  • 后台报表等计算密集型操作 → 用缓存保存计算结果,避免重复运算

服务端缓存分为两大类:

3.1 进程缓存(JVM 堆内存)

虽然进程缓存读写效率最高,但 JVM 堆内存有限,且在分布式环境下难以同步各节点间的缓存状态。

我们一般只将"数据量不大、更新频率较低"的数据放在进程缓存中。

实现方式一:Java 内置容器

1
2
3
4
5
6
7
8
// 静态常量
public final static String url = "https://time.geekbang.org";

// List 容器
public static List<String> cacheList = new Vector<String>();

// Map 容器
private static final Map<String, Object> cacheMap = new ConcurrentHashMap<String, Object>();

实现方式二:Guava Cache

Google 出品的内存缓存组件,高并发友好(基于分段锁,与 ConcurrentHashMap 类似),内置 LRU 数据淘汰机制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class GuavaCacheDemo {
public static void main(String[] args) {
Cache<String, String> cache = CacheBuilder.newBuilder()
.maximumSize(2)
.build();
cache.put("key1", "value1");
cache.put("key2", "value2");
cache.put("key3", "value3"); // 超过 maximumSize,触发淘汰

System.out.println("第一个值:" + cache.getIfPresent("key1")); // null(被淘汰)
System.out.println("第二个值:" + cache.getIfPresent("key2")); // value2
System.out.println("第三个值:" + cache.getIfPresent("key3")); // value3
}
}

运行结果:

1
2
3
第一个值:null       ← key1 被 LRU 淘汰了
第二个值:value2 ← 还在缓存内
第三个值:value3 ← 最新的,还在缓存内

实现方式三:Ehcache

如果数据量较大且需要更丰富的淘汰策略(TTL、LFU 等)或磁盘持久化能力,Ehcache 是不错的选择(Apache 开源,Hibernate 默认集成)。

3.2 分布式缓存(Redis)

对于数据一致性要求严格的场景,Ehcache 不再适用——此时应选用 Redis 作为分布式缓存方案。

为什么是 Redis?

特性 说明
性能 纯内存操作 + 单线程串行模型,读速超过 10 万次/秒
数据结构 String / List / Set / Hash / ZSet / Stream
特性支持 数据淘汰策略(LRU/LFU)、持久化(RDB/AOF)、事务、发布订阅
生态 主从复制、Sentinel 哨兵、Cluster 集群

两类缓存介绍完毕,接下来看看实际应用中的常见问题。

四、数据库与缓存的数据一致性问题

查询时先读缓存 → 缓存未命中查库 → 结果回填缓存。这个流程看似完美,但当数据被修改或删除时需要同时操作缓存和数据库,就会遇到一致性问题。

典型场景:删除操作的不一致

假设操作 A 要删除某条数据:

方案一:先删缓存,再删数据库

1
2
3
4
5
时间线:
A: 删除缓存 ✅
B: 查询缓存(空) → 查数据库(有数据!) → 回填缓存 ❌
A: 删除数据库... (还没删完)
→ 结果: 数据库空了,但缓存里还有旧数据 → 不一致!

方案二:先删数据库,再删缓存

1
2
3
4
时间线:
A: 删除数据库 ✅
A: 删除缓存... 失败! ❌
→ 结果: 缓存里还有旧数据,但数据库已删除 → 不一致!

两种顺序都无法完全保证一致性。常见的缓解思路是引入消息队列做异步串行化:

  1. 操作 A 变更数据时,先删缓存,再将操作放入线程安全队列
  2. 后台线程消费队列,执行数据库操作
  3. 若同时有读请求 B 发现缓存为空 → 检查队列 → 该 key 正在处理中 → 阻塞等待 → A 完成后唤醒 B 再去查库

但这种方案也有缺陷:读请求可能被长时间阻塞,高并发下吞吐量下降。

如果数据更新频繁且有较强的一致性要求,通常不建议使用缓存。

五、缓存穿透、击穿与雪崩

对于大规模使用分布式缓存的系统,除了一致性问题外,还需警惕三大经典异常场景:

5.1 缓存穿透(Cache Penetration)

现象:大量请求查询的 key 在缓存和数据库中都不存在,流量全部打到数据库。

解决方案对比:

方案 原理 风险
空值缓存 首次查库为空时也缓存结果(设短 TTL) 大量恶意不存在的 key 占满内存
布隆过滤器 (BloomFilter) 用 bit 数组 + 多哈希函数判断 key 是否”可能存在” 有一定误判率,但不影响正确性(漏判率为 0)

BloomFilter 原理(类似 Redis BitMap):

  1. 初始化长度为 m 的位数组,全部置 0
  2. 插入元素时用 n 个 hash 函数计算出 n 个位置,全部置 1
  3. 查询时同样计算 n 个位置 —— 全为 1 则”可能存在”,任一为 0 则”一定不存在”

d939bf92331838da581c4b500e7473a3.webp

为什么不能删除元素? 因为多个元素可能共享同一个 bit 位,删除一个会影响其他元素。解决方式是重建 BitArray。

5.2 缓存击穿(Cache Breakdown)

现象:某个热点 key 突然过期(或缓存宕机),同一时刻大量并发请求全部穿透到数据库。

对策:加互斥锁(排他锁),让只有一个线程去查库重建缓存,其余线程等待。

5.3 缓存雪崩(Cache Avalanche)

现象:与击穿类似,但规模更大——大量 key 同时过期或整个缓存服务宕机,导致数据库瞬时压力暴增。

触发原因 解决方案
大量 key 同一时间到期 在原始 TTL 上加随机偏移量,分散过期时间窗口
缓存服务宕机 构建高可用集群(Redis Sentinel / Cluster)、服务降级/限流

六、总结

层级 方案 适用场景
前端 协商缓存(304/ETag)+ 强缓存(Cache-Control) 静态资源、常量数据
网关 CDN 边缘节点缓存 视频、图片等大文件
服务端 - 进程 JVM 堆内存 / Guava Cache / Ehcache 小数据量、低频更新、弱一致要求
服务端 - 分布式 Redis Cluster 大数据量、高频更新、强一致要求

核心原则:

  1. 一致性要求极高 → 谨慎使用缓存,或接受最终一致性
  2. 必须使用缓存时 → 做好防护:防穿透(BloomFilter)、防击穿(互斥锁)、防雪崩(随机 TTL + 集群)
  3. 没有银弹 → 根据业务场景选择合适的缓存层级和策略