如何使用缓存优化系统性能?
如何使用缓存优化系统性能?
一、本地缓存(浏览器端)
平时使用 Fiddler 或浏览器 DevTools 时,我们经常会看到一些接口返回 304 状态码 + Not Modified:

如果不了解前端缓存技术,很容易对此感到困惑。浏览器的本地缓存主要分为两种机制:协商缓存和强缓存。
1.1 协商缓存
协商缓存,顾名思义就是与服务端协商之后,通过协商结果来判断是否使用本地缓存。
实现方式有两种,原理相同:
| 方案 | 请求头 | 响应头 | 判断依据 |
|---|---|---|---|
| 基于时间 | If-Modified-Since |
Last-Modified |
文件最后修改时间 |
| 基于唯一标识 | If-None-Match |
ETag |
内容哈希值 |
推荐 ETag 方案——它可以更准确地判断文件内容是否被修改,避免时间篡改导致的不可靠问题。
完整流程(以 ETag 为例):
- 首次请求:服务器返回资源的同时,在 Response 头部加上 ETag 唯一标识(根据资源内容生成)
- 再次请求:浏览器在 Request 头部带上
If-None-Match: <上次ETag> - 服务端比对:
- 值相等 → 返回 304 Not Modified(浏览器从本地缓存加载)
- 值不相等 → 返回 200 OK + 新资源 + 新 ETag
1.2 强缓存
强缓存指的是只要判断缓存没有过期,则直接使用浏览器的本地缓存。如下图所示,返回的是 200 状态码,但在 size 项中标识的是 memory cache:

强缓存通过两个 HTTP Response Header 实现:
| Header | 类型 | 说明 |
|---|---|---|
Expires |
绝对时间 | 具体的过期时间点 |
Cache-Control |
相对时间 | 过期时长(如 max-age=3600) |
⚠️ 建议使用
Cache-Control。基于 Expires 的强缓存会因为客户端/服务端时间不一致导致缓存管理问题。
流程:
- 首次请求:Response 头部带上
Cache-Control: max-age=3600 - 再次请求:浏览器计算资源是否过期 —— 未过期直接用缓存,已过期才向服务器请求
- 服务端响应:更新
Cache-Control时间
二、缓存网关(CDN)
除了浏览器本地缓存,还可以在网关层设置缓存——也就是熟悉的 CDN(内容分发网络)。
CDN 通过不同地理位置的缓存节点存储资源副本,当用户访问时由最近的节点返回资源,大幅降低延迟。这种方式常用于视频、图片等静态资源的加速。
三、服务层缓存技术
前端缓存主要用于不常修改的常量数据和静态资源文件,而大部分接口数据的缓存都在服务端进行统一管理。
缓存的初衷
- 数据库并发查询压力过大 → 用缓存减轻数据库压力
- 后台报表等计算密集型操作 → 用缓存保存计算结果,避免重复运算
服务端缓存分为两大类:
3.1 进程缓存(JVM 堆内存)
虽然进程缓存读写效率最高,但 JVM 堆内存有限,且在分布式环境下难以同步各节点间的缓存状态。
我们一般只将"数据量不大、更新频率较低"的数据放在进程缓存中。实现方式一:Java 内置容器
1 | // 静态常量 |
实现方式二:Guava Cache
Google 出品的内存缓存组件,高并发友好(基于分段锁,与 ConcurrentHashMap 类似),内置 LRU 数据淘汰机制:
1 | public class GuavaCacheDemo { |
运行结果:
1 | 第一个值:null ← key1 被 LRU 淘汰了 |
实现方式三: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 | 时间线: |
方案二:先删数据库,再删缓存
1 | 时间线: |
两种顺序都无法完全保证一致性。常见的缓解思路是引入消息队列做异步串行化:
- 操作 A 变更数据时,先删缓存,再将操作放入线程安全队列
- 后台线程消费队列,执行数据库操作
- 若同时有读请求 B 发现缓存为空 → 检查队列 → 该 key 正在处理中 → 阻塞等待 → A 完成后唤醒 B 再去查库
但这种方案也有缺陷:读请求可能被长时间阻塞,高并发下吞吐量下降。
如果数据更新频繁且有较强的一致性要求,通常不建议使用缓存。
五、缓存穿透、击穿与雪崩
对于大规模使用分布式缓存的系统,除了一致性问题外,还需警惕三大经典异常场景:
5.1 缓存穿透(Cache Penetration)
现象:大量请求查询的 key 在缓存和数据库中都不存在,流量全部打到数据库。
解决方案对比:
| 方案 | 原理 | 风险 |
|---|---|---|
| 空值缓存 | 首次查库为空时也缓存结果(设短 TTL) | 大量恶意不存在的 key 占满内存 |
| 布隆过滤器 (BloomFilter) | 用 bit 数组 + 多哈希函数判断 key 是否”可能存在” | 有一定误判率,但不影响正确性(漏判率为 0) |
BloomFilter 原理(类似 Redis BitMap):
- 初始化长度为 m 的位数组,全部置 0
- 插入元素时用 n 个 hash 函数计算出 n 个位置,全部置 1
- 查询时同样计算 n 个位置 —— 全为 1 则”可能存在”,任一为 0 则”一定不存在”

为什么不能删除元素? 因为多个元素可能共享同一个 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 | 大数据量、高频更新、强一致要求 |
核心原则:
- 一致性要求极高 → 谨慎使用缓存,或接受最终一致性
- 必须使用缓存时 → 做好防护:防穿透(BloomFilter)、防击穿(互斥锁)、防雪崩(随机 TTL + 集群)
- 没有银弹 → 根据业务场景选择合适的缓存层级和策略