1. 引言
文章目录
-
- [1. 引言](#1. 引言)
- [2. 缓存优化](#2. 缓存优化)
-
- [2.1 缓存击穿:逻辑过期方案](#2.1 缓存击穿:逻辑过期方案)
- [2.2 缓存穿透:缓存空值方案](#2.2 缓存穿透:缓存空值方案)
- [2.3 布隆过滤器:缓存穿透的进阶防线](#2.3 布隆过滤器:缓存穿透的进阶防线)
-
- [2.3.1 布隆过滤器的原理](#2.3.1 布隆过滤器的原理)
- [2.3.2 为什么存在误判?如何优化?](#2.3.2 为什么存在误判?如何优化?)
- [2.3.3 为什么无法删除数据?如何解决?](#2.3.3 为什么无法删除数据?如何解决?)
- [3. 数据一致性](#3. 数据一致性)
-
- [3.1 更新数据库后删除缓存](#3.1 更新数据库后删除缓存)
- [3.2 删除失败:消息队列补偿重试](#3.2 删除失败:消息队列补偿重试)
- [3.3 TTL 兜底](#3.3 TTL 兜底)
- [4. 多级缓存:Caffeine + Redis](#4. 多级缓存:Caffeine + Redis)
-
- [4.1 为什么需要多级缓存](#4.1 为什么需要多级缓存)
- [4.2 二级缓存架构](#4.2 二级缓存架构)
- [4.3 代码实现](#4.3 代码实现)
- [4.4 多级缓存注意事项](#4.4 多级缓存注意事项)
- [5. 总结](#5. 总结)
在高并发场景下,缓存是提升系统性能、降低数据库压力的核心手段。然而,缓存的使用并非"一放了之",如果设计不当,反而会引入缓存击穿、缓存穿透、数据不一致等一系列问题。本文结合生产实践,围绕以下三个主题展开:
- 缓存优化:使用逻辑过期方案防止 Redis 热点 Key 的缓存击穿问题;使用缓存空值方案解决 Redis Key 的缓存穿透问题。
- 数据一致性:更新数据库后删除缓存,若删除失败则通过消息队列补偿重试,并以 TTL 兜底,共同保证数据一致性。
- 多级缓存:使用 Caffeine 本地缓存和 Redis 缓存搭建二级缓存架构,提高热点数据访问速度,降低 Redis 压力。
2. 缓存优化
2.1 缓存击穿:逻辑过期方案
缓存击穿指的是某个热点 Key 在缓存过期的瞬间,大量并发请求同时打到数据库,导致数据库压力骤增甚至宕机。
常见的解决方案有互斥锁和逻辑过期。这里重点介绍逻辑过期方案。
逻辑过期的核心思想是:不设置真正的 TTL,而是将过期时间作为业务字段写入缓存值中。读取时判断逻辑时间是否过期,若过期则尝试获取互斥锁,由单个线程重建缓存,其他线程先返回旧数据。
java
public class RedisCacheService {
private static final String LOCK_PREFIX = "lock:";
/**
* 写入缓存(逻辑过期)
*/
public void setWithLogicalExpire(String key, Object value, long expireSeconds) {
// 封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(System.currentTimeMillis() + expireSeconds * 1000);
stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(redisData));
}
/**
* 读取缓存(逻辑过期 + 互斥锁重建)
*/
public Object getWithLogicalExpire(String key, Class<?> type, IDomainService service) {
String json = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isBlank(json)) {
return null;
}
RedisData redisData = JSON.parseObject(json, RedisData.class);
Object value = redisData.getData();
long expireTime = redisData.getExpireTime();
// 1. 未过期,直接返回
if (expireTime > System.currentTimeMillis()) {
return value;
}
// 2. 已过期,尝试获取互斥锁
String lockKey = LOCK_PREFIX + key;
boolean locked = tryLock(lockKey);
if (locked) {
try {
// 重建缓存
Object newValue = service.queryFromDatabase();
setWithLogicalExpire(key, newValue, 30L);
return newValue;
} finally {
unlock(lockKey);
}
}
// 3. 未获取到锁,返回旧数据
return value;
}
}
优点:在缓存重建期间,其他线程仍能读到旧数据,不会直接打到数据库,也不会出现"缓存雪崩"式的空窗期。
2.2 缓存穿透:缓存空值方案
缓存穿透 指的是请求查询一个根本不存在的数据,缓存和数据库中都没有,导致每次请求都穿透到数据库。
解决方案之一是缓存空值:当数据库查询结果为空时,仍然将该 Key 写入缓存,但 Value 为空值,并设置较短的 TTL(如 2~5 分钟),防止恶意请求反复穿透。
java
public Object queryWithCacheThrough(String key, Class<?> type, IDomainService service) {
// 1. 查询缓存
String json = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(json)) {
return JSON.parseObject(json, type);
}
// 2. 缓存未命中,查询数据库
Object value = service.queryFromDatabase();
// 3. 数据库为空,缓存空值
if (value == null) {
stringRedisTemplate.opsForValue().set(key, "", 5L, TimeUnit.MINUTES);
return null;
}
// 4. 数据库有值,写入缓存
stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(value), 30L, TimeUnit.MINUTES);
return value;
}
注意 :缓存空值只能缓解穿透,无法完全杜绝。对于极端恶意攻击,还应配合布隆过滤器在缓存之前做一层拦截。
2.3 布隆过滤器:缓存穿透的进阶防线
缓存空值只能缓解穿透,对于极端恶意攻击,更推荐在缓存之前加一层布隆过滤器(Bloom Filter),把大量不存在的 Key 直接拦截在缓存层之外。
2.3.1 布隆过滤器的原理
布隆过滤器是一种空间效率极高的概率型数据结构,主要用于快速判断某个元素是否属于某个集合。它的底层由两个极简组件构成:
- 二进制位数组(Bit Array):一个长度为 m 的位数组,初始化时所有位置均置为 0。
- k 个相互独立的哈希函数:能将任意输入元素均匀地映射到位数组的不同下标位置。
工作流程如下:
- 添加元素:将元素依次输入 k 个哈希函数,得到 k 个数组下标,并将位数组中这些位置的 Bit 值从 0 置为 1。
- 查询元素 :同样使用这 k 个哈希函数计算目标元素的 k 个位置。只要这 k 个位置中有任何一个为 0,就 100% 确定该元素不在集合中;如果这 k 个位置全部为 1,则判定该元素"可能存在"于集合中。
java
public class BloomFilterDemo {
private final BitSet bits = new BitSet(1 << 20); // 位数组
private final int k = 3; // 哈希函数个数
// 模拟 k 个哈希函数
private int hash(String value, int seed) {
int h = value.hashCode() ^ (seed * 0x9e3779b9);
return Math.abs(h) % bits.size();
}
// 添加元素
public void add(String value) {
for (int i = 0; i < k; i++) {
bits.set(hash(value, i));
}
}
// 查询元素:任一位置为 0 则一定不存在
public boolean mightContain(String value) {
for (int i = 0; i < k; i++) {
if (!bits.get(hash(value, i))) {
return false;
}
}
return true;
}
}
2.3.2 为什么存在误判?如何优化?
误判原因(假阳性):误判的根源在于哈希碰撞。由于位数组长度有限,不同元素经过哈希计算后可能映射到相同的 Bit 位置,导致一个从未录入的元素,其对应的 k 个位置碰巧都被其他元素置为 1,从而被误判为存在。
优化/解决方案:
- 增加位数组长度(m):增大空间是最直接有效的方法,能显著降低哈希碰撞概率。
- 优化哈希函数数量(k) :哈希函数并非越多越好,存在理论最优值
k ≈ 0.693 × (m / n)(n 为元素数量),此时误判率最低。 - 使用高质量哈希函数:采用分布更均匀的哈希算法(如 MurmurHash3、xxHash),避免位聚集。
- 业务层二次校验(回源查询):布隆过滤器仅保证"一定不存在"的确定性。当它返回"可能存在"时,再回源查询数据库或精确数据结构进行最终确认。
- 动态扩展(Scalable Bloom Filter):当元素数量接近设计容量时,创建新的一层布隆过滤器进行扩容,以维持设定的误判率。
2.3.3 为什么无法删除数据?如何解决?
无法删除原因:布隆过滤器的底层是位映射结构,位数组中的每一个 Bit 并不是独占的,而是被多个元素共享。如果尝试删除某个元素,需要将其对应的 k 个 Bit 位清零,但这会不可避免地清除掉其他共享这些位置的元素的存在痕迹,导致后续查询其他元素时出现"假阴性"(把存在的元素误判为不存在)。
优化/解决方案:
- 计数布隆过滤器(Counting Bloom Filter):将原本的 1 个 Bit 扩展为计数器(通常使用 4 bits)。插入时对应位置计数加 1,删除时计数减 1,查询时检查计数器是否均大于 0。代价是内存消耗会增加 3~4 倍。
- 布谷鸟过滤器(Cuckoo Filter):基于布谷鸟哈希算法,使用"指纹存储"和"两个候选桶"机制。它不仅空间利用率更高,而且天然支持高效的删除操作。
- 定期重建:在业务低谷期(如每天凌晨),基于最新的有效数据重新构建一个全新的布隆过滤器,以清除已删除元素带来的影响。
- 结合其他数据结构:将布隆过滤器作为前置的"快速否定器",配合哈希表等精确数据结构使用。布隆过滤器负责过滤大量不存在的元素,精确结构负责处理实际的删除和最终判定。
3. 数据一致性
3.1 更新数据库后删除缓存
为了保证数据库与缓存的一致性,业界常用 Cache Aside(旁路缓存) 模式:先更新数据库,再删除缓存。这样即使删除缓存失败,也只会短暂出现脏数据,而不会长期不一致。
3.2 删除失败:消息队列补偿重试
如果删除缓存失败,直接忽略会导致缓存长期为脏数据。因此引入消息队列补偿重试机制:
- 更新数据库成功后,发送一条"删除缓存"的消息到 MQ;
- 消费者收到消息后尝试删除缓存;
- 若删除失败,则进行重试(可设置最大重试次数);
- 重试仍失败,则记录日志并告警,人工介入。
java
public void updateData(Long id, Object newData) {
// 1. 更新数据库
databaseMapper.update(id, newData);
// 2. 发送删除缓存消息(补偿机制)
MQMessage message = new MQMessage();
message.setKey("cache:user:" + id);
mqProducer.send("cache.delete.topic", message);
}
为啥会删除失败
| 失败类型 | 典型原因 | 结果 |
|---|---|---|
| 网络/调用失败 | 应用调用 Redis 时网络抖动、超时、连接池耗尽 | DEL 没执行或响应丢失 |
| Redis 服务异常 | Redis OOM、主从切换、集群故障、持久化阻塞 | 删除指令失败或未被正确传播 |
| 应用侧异常 | 更新 DB 后进程崩溃、Pod 重启、try-catch 吞掉异常 | 删除代码根本没执行 |
| 删除成功但数据仍残留 | 主从切换后删除未同步;并发读请求把旧值重新写回缓存 | 看起来删了,但缓存还是旧数据 |
| Key 算错/漏删 | 同一数据有多个缓存 Key,只删了主键缓存,没删列表、聚合缓存 | 部分缓存仍是旧值 |
为啥先更新数据库
- 数据库是事实源,必须先落库
缓存只是副本。先保证 MySQL 提交成功,再让缓存失效,下一次读请求会按统一读路径重新从数据库加载。 - 比"先删缓存再更新 DB"安全得多
如果先删缓存,在 DB 还没更新完时,读请求会命中空缓存,从数据库读到旧值并回填。结果就是:DB 已更新,缓存却长期保留旧值,直到 TTL 过期。 - 删除比更新缓存更不容易出错
更新缓存需要重新计算读模型,并发写时容易出现"谁后写谁赢"的覆盖问题。删除缓存只是让旧副本失效,幂等、简单、失败后重试也安全。 - 删除失败更容易兜底
先更新 DB 后删缓存,如果删除失败,缓存里是旧值,但可以通过 MQ 重试、Canal 监听 Binlog、TTL 兜底恢复。
3.3 TTL 兜底
即便消息队列补偿也失败,缓存 Key 仍带有 TTL,到期后自动失效,最终会重新从数据库加载,从而保证最终一致性 。因此,TTL 是数据一致性的最后一道兜底防线。
4. 多级缓存:Caffeine + Redis
4.1 为什么需要多级缓存
Redis 虽然快,但每次访问仍有一次网络开销。对于超高并发的热点数据,可以让请求优先命中本地缓存(Caffeine),未命中再访问 Redis,最后才回源数据库。这样既能大幅降低 Redis 压力,又能显著提升响应速度。
4.2 二级缓存架构
#mermaid-svg-zMCSS8Zwh4TC7zk6{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-zMCSS8Zwh4TC7zk6 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .error-icon{fill:#552222;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .marker.cross{stroke:#333333;}#mermaid-svg-zMCSS8Zwh4TC7zk6 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-zMCSS8Zwh4TC7zk6 p{margin:0;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .cluster-label text{fill:#333;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .cluster-label span{color:#333;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .cluster-label span p{background-color:transparent;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .label text,#mermaid-svg-zMCSS8Zwh4TC7zk6 span{fill:#333;color:#333;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .node rect,#mermaid-svg-zMCSS8Zwh4TC7zk6 .node circle,#mermaid-svg-zMCSS8Zwh4TC7zk6 .node ellipse,#mermaid-svg-zMCSS8Zwh4TC7zk6 .node polygon,#mermaid-svg-zMCSS8Zwh4TC7zk6 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .rough-node .label text,#mermaid-svg-zMCSS8Zwh4TC7zk6 .node .label text,#mermaid-svg-zMCSS8Zwh4TC7zk6 .image-shape .label,#mermaid-svg-zMCSS8Zwh4TC7zk6 .icon-shape .label{text-anchor:middle;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .rough-node .label,#mermaid-svg-zMCSS8Zwh4TC7zk6 .node .label,#mermaid-svg-zMCSS8Zwh4TC7zk6 .image-shape .label,#mermaid-svg-zMCSS8Zwh4TC7zk6 .icon-shape .label{text-align:center;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .node.clickable{cursor:pointer;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .arrowheadPath{fill:#333333;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-zMCSS8Zwh4TC7zk6 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zMCSS8Zwh4TC7zk6 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-zMCSS8Zwh4TC7zk6 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .cluster text{fill:#333;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .cluster span{color:#333;}#mermaid-svg-zMCSS8Zwh4TC7zk6 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-zMCSS8Zwh4TC7zk6 rect.text{fill:none;stroke-width:0;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .icon-shape,#mermaid-svg-zMCSS8Zwh4TC7zk6 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .icon-shape p,#mermaid-svg-zMCSS8Zwh4TC7zk6 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .icon-shape rect,#mermaid-svg-zMCSS8Zwh4TC7zk6 .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zMCSS8Zwh4TC7zk6 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-zMCSS8Zwh4TC7zk6 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-zMCSS8Zwh4TC7zk6 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 未命中
未命中
回填
回填
命中
命中
客户端请求
Caffeine 本地缓存
Redis 缓存
数据库
返回响应
4.3 代码实现
java
@Component
public class MultiLevelCacheService {
@Autowired
private StringRedisTemplate stringRedisTemplate;
private final Cache<String, Object> caffeineCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build();
public Object query(String key, IDomainService service) {
// 1. 查本地缓存
Object local = caffeineCache.getIfPresent(key);
if (local != null) {
return local;
}
// 2. 查 Redis
String json = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(json)) {
Object value = JSON.parseObject(json, Object.class);
caffeineCache.put(key, value); // 回填本地缓存
return value;
}
// 3. 查数据库
Object value = service.queryFromDatabase();
if (value != null) {
stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(value), 30L, TimeUnit.MINUTES);
caffeineCache.put(key, value);
}
return value;
}
}
4.4 多级缓存注意事项
- 本地缓存一致性:Caffeine 是 JVM 级缓存,多实例部署时各节点数据可能不一致,需配合消息通知或短 TTL 控制。
- 内存占用:本地缓存不宜过大,建议设置最大容量和过期时间。
- 更新策略:更新数据时,需要同时删除本地缓存和 Redis 缓存,避免脏读。
5. 总结
本文围绕项目缓存的核心痛点,给出了三套可落地的实践方案:
| 问题 | 方案 | 关键点 |
|---|---|---|
| 缓存击穿 | 逻辑过期 + 互斥锁 | 过期期间返回旧数据,单线程重建 |
| 缓存穿透 | 缓存空值 | 空值短 TTL,配合布隆过滤器更佳 |
| 数据一致性 | 删缓存 + MQ 补偿 + TTL 兜底 | 最终一致性 |
| 高并发热点 | Caffeine + Redis 二级缓存 | 降低 Redis 压力,提升响应速度 |
缓存设计没有银弹,需要结合业务场景、并发量、一致性要求综合权衡。希望本文的实践能为你提供参考。