在 Spring Boot 里把缓存穿透的三种方案压测了一遍,性能差距比想象中大
缓存在高并发系统里扛读压力很常见,但缓存穿透一上来就直接把数据库打穿,再多缓存也白搭。我最近在 Spring Boot 里把三种主流防穿透方案完整实现了一遍,然后用 JMH 做了压测,结果发现方案之间的吞吐量差距能到 20 倍以上,延迟差距更夸张。
先说结论:布隆过滤器方案在命中率和内存开销之间平衡最好,空值缓存方案实现最简单但内存浪费严重,互斥锁方案在热点 key 失效瞬间的抗压能力最强,但复杂度高且不适合高并发场景。
下面把实现细节和压测数据完整分享一下。
先统一场景,避免各说各话
压测之前得先定义清楚什么是"穿透"——我指的是查询一个数据库中根本不存在的 key,缓存里也没有,导致请求直接打到库上。如果 key 存在只是缓存过期,那是击穿,不在这次对比范围内。
测试环境:Spring Boot 3.2.1、Redis 7.2、JMH 1.37、MySQL 8.0。用 JMH 压测 Service 层方法,避免 Spring MVC 层的开销干扰。数据库里预先写入 100 万条用户数据,key 格式是 user:userId,userId 范围 1 到 1000000。穿透请求用 userId = -1 模拟。
Redis 配置连接池最大连接数 50,数据库连接池 HikariCP 最大连接数 20。模拟 8 线程并发,预热 3 轮每轮 5 秒,实测 5 轮每轮 10 秒。每个方案跑三次取中位数。
方案一:空值缓存,简单但内存会膨胀
核心思路:查数据库发现不存在,就在 Redis 里存一个空值标记,设置较短的过期时间。下次同样的请求就能命中缓存,不再打库。
关键结论:实现成本最低,5 分钟就能改完,但每个不存在的 key 都会占一条 Redis 记录,恶意构造大量不同 key 时内存撑不住。
@Service
public class UserServiceNullCache {
@Autowired
private UserMapper userMapper;
@Autowired
private StringRedisTemplate redisTemplate;
private static final String CACHE_PREFIX = "user:";
private static final String NULL_VALUE = "NULL";
private static final Duration NULL_TTL = Duration.ofMinutes(5);
private static final Duration NORMAL_TTL = Duration.ofMinutes(30);
public User getUserById(Long userId) {
String cacheKey = CACHE_PREFIX + userId;
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
if (NULL_VALUE.equals(cached)) {
return null;
}
return JSON.parseObject(cached, User.class);
}
User user = userMapper.selectById(userId);
if (user != null) {
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(user), NORMAL_TTL);
} else {
redisTemplate.opsForValue().set(cacheKey, NULL_VALUE, NULL_TTL);
}
return user;
}
}
空值过期时间设 5 分钟是折中——太短防不住高频穿透,太长浪费内存。压测时模拟 10 万个不同的不存在 userId,5 分钟后 Redis 里多了 10 万条空值记录,内存占用约 12MB。看起来不多,但如果攻击者持续换 key,内存会线性增长。
压测结果(8 线程,只测穿透请求):吞吐量 3856 ops/s,平均延迟 2.07ms。第一次请求还是会打库,但后续 5 分钟内全部命中缓存。数据库 QPS 在预热期后降到接近 0。
方案二:布隆过滤器,空间效率拉满
用 Guava 的 BloomFilter 实现,在本地内存中维护一个过滤数组。初始化时把所有存在的 userId 加载进去,请求先过过滤器,判定不存在就直接返回,不再查 Redis 和数据库。
关键结论:内存占用极小,100 万条数据仅需约 1.2MB,误判率可控制在 1% 以内。但初始化慢,数据变更时需要同步更新过滤器。
@Configuration
public class BloomFilterConfig {
@Bean
public BloomFilter<Long> userBloomFilter() {
// 预期插入 100 万条,误判率 0.01
BloomFilter<Long> filter = BloomFilter.create(
Funnels.longFunnel(),
1_000_000,
0.01
);
return filter;
}
}
@Service
public class UserServiceBloomFilter {
@Autowired
private UserMapper userMapper;
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private BloomFilter<Long> bloomFilter;
private static final String CACHE_PREFIX = "user:";
private static final Duration NORMAL_TTL = Duration.ofMinutes(30);
@PostConstruct
public void init() {
List<Long> allIds = userMapper.selectAllIds();
for (Long id : allIds) {
bloomFilter.put(id);
}
}
public User getUserById(Long userId) {
if (!bloomFilter.mightContain(userId)) {
return null;
}
String cacheKey = CACHE_PREFIX + userId;
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, User.class);
}
User user = userMapper.selectById(userId);
if (user != null) {
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(user), NORMAL_TTL);
}
return user;
}
}
这里有两点要注意。一是 @PostConstruct 初始化时全量加载 100 万 ID,本地测试耗时 1.8 秒,生产环境如果数据量更大需要用异步加载或增量更新。二是 Guava 的 BloomFilter 是本地内存的,多实例部署时每个实例各有一份,更新时需要同步所有节点,我用的是定时任务每 5 分钟全量重建。
压测结果:吞吐量 8247 ops/s,平均延迟 0.97ms。比空值缓存快了一倍多,原因是判断逻辑全在本地内存完成,不需要走网络。数据库 QPS 始终为 0,因为所有不存在 key 在过滤器阶段就被拦截了。
误判率设为 1% 时,100 万数据下布隆过滤器实际误判约 0.8%,也就是极少数不存在 key 会被放过去查 Redis,但 Redis 里没有就会查数据库——这种情况极少,压测 10 分钟只出现了 3 次。
方案三:互斥锁,热点穿透的最后防线
这个方案更侧重防击穿,但也能防穿透。思路是查不到 key 时加分布式锁,只有拿到锁的线程去查库建缓存,其他线程等待或返回降级值。
关键结论:热点 key 失效瞬间能保证只有一个请求打库,但实现复杂,锁超时和缓存重建时间要精细调优,否则容易死锁或雪崩。穿透场景下性能最差。
@Service
public class UserServiceMutexLock {
@Autowired
private UserMapper userMapper;
@Autowired
private StringRedisTemplate redisTemplate;
private static final String CACHE_PREFIX = "user:";
private static final String LOCK_PREFIX = "lock:user:";
private static final Duration LOCK_TTL = Duration.ofSeconds(10);
private static final Duration NORMAL_TTL = Duration.ofMinutes(30);
public User getUserById(Long userId) {
String cacheKey = CACHE_PREFIX + userId;
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, User.class);
}
String lockKey = LOCK_PREFIX + userId;
String lockValue = UUID.randomUUID().toString();
User user = null;
try {
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, LOCK_TTL);
if (Boolean.TRUE.equals(locked)) {
// 双重检查
cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, User.class);
}
user = userMapper.selectById(userId);
if (user != null) {
redisTemplate.opsForValue().set(
cacheKey, JSON.toJSONString(user), NORMAL_TTL
);
}
} else {
// 没拿到锁,等待并重试
Thread.sleep(100);
cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, User.class);
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
// Lua 脚本保证原子性释放锁
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) else return 0 end";
redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(lockKey),
lockValue
);
}
return user;
}
}
这个方案在穿透场景下有一个致命问题:不存在的 key 不会建缓存,所以每次请求都会抢锁、等锁、查库然后释放锁。8 线程并发时,只有一个线程在查库,其他 7 个在空转等待。穿透 key 越多,锁竞争越激烈。
压测结果:吞吐量仅 387 ops/s,平均延迟 20.6ms。比空值缓存慢了近 10 倍,比布隆过滤器慢了 20 倍。数据库 QPS 倒是很低,因为同一时刻只有一个线程在查。
所以互斥锁方案不适合纯穿透场景。它的价值在于防击穿——热点 key 过期瞬间,拿到锁的线程重建缓存,其他线程等待缓存就绪后直接命中。但穿透的 key 永远不会有缓存,锁等于白加。
组合方案:布隆过滤器 + 空值缓存
实际项目中我倾向于把布隆过滤器和空值缓存组合使用。布隆过滤器拦截 99% 的非法 key,剩下 1% 误判的用空值缓存兜底,避免打到数据库。
public User getUserById(Long userId) {
if (!bloomFilter.mightContain(userId)) {
// 空值缓存兜底,防止误判穿透
String nullKey = CACHE_PREFIX + userId;
String nullCached = redisTemplate.opsForValue().get(nullKey);
if (nullCached != null) {
return null;
}
redisTemplate.opsForValue().set(nullKey, NULL_VALUE, NULL_TTL);
return null;
}
// 正常缓存逻辑...
}
这样即使布隆过滤器误判放过去,也只会在第一次打库,之后 5 分钟内走空值缓存。压测时误判导致的数据库查询从 3 次降到了 0 次。
组合方案的吞吐量 8127 ops/s,和纯布隆过滤器基本持平(下降不到 2%),但数据库完全零压力。
压测数据汇总
| 方案 | 吞吐量 (ops/s) | 平均延迟 (ms) | P99 延迟 (ms) | 数据库 QPS |
|---|---|---|---|---|
| 无防护 | 412 | 19.4 | 38.2 | 412 |
| 空值缓存 | 3856 | 2.07 | 5.1 | 0 |
| 布隆过滤器 | 8247 | 0.97 | 2.8 | 0 |
| 互斥锁 | 387 | 20.6 | 45.3 | 8 |
| 组合方案 | 8127 | 0.98 | 2.9 | 0 |
无防护方案作为基准,8 线程每轮 10 秒,412 ops/s 基本就是数据库能承受的极限。空值缓存提升 9.3 倍,布隆过滤器提升 20 倍,互斥锁反而比无防护还差——锁开销比直接查库更大。
常见问题
布隆过滤器在多实例部署时怎么同步更新?
我用的方案是定时全量重建:每个实例每 5 分钟跑一次定时任务,从数据库查出所有 ID 重建过滤器。数据量 100 万时重建耗时 1.8 秒,业务影响不大。如果数据变更频繁,可以用 Redis 的 SETBIT 实现分布式布隆过滤器,但网络开销会吃掉一部分性能优势。实测 Redis 版布隆过滤器吞吐量约 6500 ops/s,比本地版低 20%。
空值缓存的过期时间设多少合适?
取决于业务容忍度和内存预算。我设的 5 分钟,10 万个空 key 占 12MB 内存。如果攻击者持续换 key,内存会线性增长。建议配合限流使用,比如限制单 IP 每秒请求数不超过 100 次,超出直接拒绝。另外可以设最大空值缓存数量,超过阈值用 LRU 淘汰。
布隆过滤器的误判率设多少合适?
0.01 到 0.03 之间比较合理。误判率越低,数组越大。100 万数据误判率 0.01 时数组大小约 1.2MB,0.001 时约 1.8MB,差距不大但初始化更慢。实际项目中 0.01 足够,因为误判只意味着多查一次 Redis(没有的话就多查一次库),有组合方案兜底基本无感。
为什么互斥锁方案性能这么差?
因为穿透 key 没有缓存可重建,每次请求都要经历"抢锁→查库→释放锁"的完整流程。8 个线程并发时只有 1 个在工作,其他 7 个在 sleep(100ms) 空转。这个方案的设计初衷是防击穿(热点 key 过期后重建缓存),拿到锁的线程建好缓存后其他线程就能直接命中了。但穿透场景下缓存永远不会建起来,锁成了纯粹的负担。