在 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 过期后重建缓存),拿到锁的线程建好缓存后其他线程就能直接命中了。但穿透场景下缓存永远不会建起来,锁成了纯粹的负担。