把缓存想象成数据库门前的防弹玻璃,而穿透和击穿是两种截然不同的攻击方式。穿透是请求的数据根本不存在,每次查询都绕过缓存直接打在数据库上;击穿是某个热点数据突然过期,瞬间大量并发请求同时砸向后端。两者造成的数据库冲击路径完全不同,防护策略也必须对症下药。

缓存穿透的本质是“查无此人”。比如电商系统中,用户反复查询一个不存在的商品ID,这个ID在数据库里没有,缓存里自然也不会有。每次请求都会穿透缓存层,直接落到数据库上执行一次无意义的查询。如果攻击者用脚本生成海量不存在的ID,数据库连接池很快就会被耗尽。

布隆过滤器的实际落地方式

解决穿透问题最有效的手段是布隆过滤器。它的原理很简单:用多个哈希函数把数据库中所有存在的ID映射到一个位数组里。查询时先过布隆过滤器,如果返回不存在,那数据库里一定没有,直接返回空结果即可,连缓存都不用查。

但布隆过滤器在实际生产中有几个坑必须注意。第一是初始化问题,系统启动时需要把数据库里所有ID加载到位数组中,如果数据量上亿,这个过程会很慢。解决方案是把布隆过滤器的状态定期序列化到本地文件或Redis里,重启时直接加载,避免每次重建。第二是数据新增或删除时布隆过滤器需要同步更新,删除尤其麻烦,因为标准布隆过滤器不支持删除操作。这时候要用计数布隆过滤器,把位数组的每个位置扩展成一个计数器,插入时加一,删除时减一。

// 使用Redisson的布隆过滤器示例
RBloomFilter<String> bloomFilter = redisson.getBloomFilter("productFilter");
// 初始化:预计元素数量1000000,误判率0.03
bloomFilter.tryInit(1000000L, 0.03);
// 预热:从数据库加载所有商品ID
List<String> allIds = productMapper.getAllIds();
allIds.forEach(bloomFilter::add);
// 查询时先过布隆过滤器
if (!bloomFilter.contains(productId)) {
    return Result.empty();
}

还有一种更轻量的方案是缓存空值。当查询数据库发现数据不存在时,在缓存里存一个空对象或特殊标记,过期时间设短一些,比如30秒到2分钟。这样后续相同请求在过期前都会命中缓存,不会打到数据库。但这种方式有两个局限:一是如果攻击者不断生成新的不存在的ID,缓存里会堆积大量空值,占用内存;二是过期时间不好把控,设长了可能影响正常业务,设短了防护效果打折扣。实际项目中通常把布隆过滤器和缓存空值结合起来用,布隆过滤器做第一道防线,缓存空值做兜底。

缓存击穿的并发竞争问题

击穿场景比穿透更隐蔽也更危险。一个热门商品的缓存刚好过期,此时正好赶上秒杀活动,几百上千个请求同时发现缓存未命中,全部涌向数据库去查同一个key。数据库瞬间承受的并不是分散的随机查询,而是对同一行数据的高并发重复读取。数据库的锁机制会让这些查询排队等待,CPU飙高,其他正常请求也被阻塞。

解决击穿的核心思路是“只让一个请求去查数据库,其他请求等待结果”。这个思路说起来简单,实现起来却要处理好分布式环境下的锁竞争、锁超时、以及锁释放后的缓存回填。

互斥锁方案的代码实现细节

用Redis实现分布式锁是业界最普遍的方案。关键点在于锁的粒度要细到单个缓存key,而不是整个业务模块。比如商品ID为1001的缓存过期了,只对“lock:product:1001”这个锁key加锁,不影响其他商品的查询。

public Product getProduct(String productId) {
    String cacheKey = "product:" + productId;
    String lockKey = "lock:product:" + productId;
    Product product = cache.get(cacheKey);
    if (product != null) {
        return product;
    }
    // 尝试获取分布式锁,设置超时时间防止死锁
    boolean locked = redis.setnx(lockKey, "1", 10, TimeUnit.SECONDS);
    if (locked) {
        try {
            // 双重检查,拿到锁后再查一次缓存
            product = cache.get(cacheKey);
            if (product != null) {
                return product;
            }
            product = db.query(productId);
            if (product != null) {
                cache.set(cacheKey, product, 30, TimeUnit.MINUTES);
            } else {
                // 缓存空值防穿透
                cache.set(cacheKey, nullObject, 60, TimeUnit.SECONDS);
            }
            return product;
        } finally {
            redis.del(lockKey);
        }
    } else {
        // 没拿到锁,等待后重试
        Thread.sleep(50);
        return getProduct(productId); // 递归重试
    }
}

这段代码里藏着一个容易忽视的细节:拿到锁之后必须再查一次缓存。因为在等待锁的过程中,上一个持有锁的线程可能已经把数据回填到缓存里了。如果不做双重检查,就会重复查询数据库,违背了加锁的初衷。

递归重试的方式在生产环境要谨慎使用,如果锁竞争激烈,递归层级太深可能导致栈溢出。更好的做法是用循环加固定次数的重试,或者用CountDownLatch之类的同步工具让等待线程被主动唤醒。Redisson提供的分布式锁已经封装了自旋等待和自动续期,实际项目中推荐直接使用成熟的客户端库,而不是手写setnx逻辑。

逻辑过期的异步更新策略

互斥锁方案有一个性能代价:在锁被持有的那段时间里,所有其他请求都必须等待。对于流量极大的热点数据,这个等待仍然会造成短暂的响应延迟。逻辑过期方案则完全避免了等待。

逻辑过期的做法是:缓存里的数据永不过期,或者说Redis的过期时间设得特别长。但在缓存的值里额外存储一个逻辑过期时间字段。每次读取缓存时,先判断逻辑时间是否过期。如果没过期,直接返回。如果过期了,当前线程立即返回旧数据,同时异步启动一个线程去更新缓存,更新过程中还会加一把分布式锁防止多个线程同时更新。

public Product getProductLogicalExpire(String productId) {
    String cacheKey = "product:" + productId;
    CacheObject cacheObj = cache.get(cacheKey);
    if (cacheObj == null) {
        // 缓存里完全没有,这种情况走互斥锁逻辑
        return getProductWithLock(productId);
    }
    if (!cacheObj.isLogicalExpired()) {
        return cacheObj.getData();
    }
    // 逻辑过期了,先返回旧数据,异步更新
    String lockKey = "lock:product:" + productId;
    if (redis.setnx(lockKey, "1", 10, TimeUnit.SECONDS)) {
        threadPool.execute(() -> {
            try {
                Product newProduct = db.query(productId);
                CacheObject newCacheObj = new CacheObject();
                newCacheObj.setData(newProduct);
                newCacheObj.setExpireTime(System.currentTimeMillis() + 30 * 60 * 1000);
                cache.set(cacheKey, newCacheObj);
            } finally {
                redis.del(lockKey);
            }
        });
    }
    return cacheObj.getData(); // 直接返回旧数据,不等待
}

这种方案牺牲了数据的实时性,换取了极高的读性能。用户可能在逻辑过期后的几百毫秒内看到旧数据,但对于商品详情、新闻内容这类场景,短暂的不一致完全可以接受。需要注意的是线程池的拒绝策略要配置好,避免异步任务被丢弃导致缓存永远不更新。

数据库层面的防护不能忽视

很多人只盯着缓存层做优化,却忘了数据库本身也有防护能力。连接池的配置直接决定了数据库能承受的冲击上限。HikariCP的maximumPoolSize不是越大越好,连接数过多会导致数据库的上下文切换开销急剧增加。一般建议根据数据库的CPU核心数来设置,公式是:连接数 = (CPU核心数 * 2) + 有效磁盘数。

SQL层面的限流也很重要。在DAO层加一个简单的计数器或令牌桶,对同一个查询方法的调用频率做限制。比如用Guava的RateLimiter,每秒只允许1000个请求通过,超出的直接快速失败返回兜底数据,而不是让它们排队等待数据库连接。

private RateLimiter rateLimiter = RateLimiter.create(1000.0);

public Product queryWithRateLimit(String productId) {
    if (!rateLimiter.tryAcquire(200, TimeUnit.MILLISECONDS)) {
        // 拿不到令牌,直接返回兜底数据或抛异常
        return fallbackProduct;
    }
    return db.query(productId);
}

数据库查询本身也要优化。对于击穿场景中反复查询的同一个key,数据库的Buffer Pool会把对应的数据页缓存起来,所以后续查询其实是内存操作,压力没有想象中那么大。但如果表上没有合适的索引,每次查询都走全表扫描,那Buffer Pool也救不了你。确保热点查询的字段上有索引,并且索引覆盖查询所需的全部列,避免回表。

多层防护的架构设计

单靠任何一种方案都无法覆盖所有场景。一个健壮的系统应该把防护措施分层部署。第一层是布隆过滤器或缓存空值,拦截不存在的key。第二层是本地缓存,比如Caffeine,把极热点数据缓存在应用进程内,进一步减少对Redis的访问。第三层是Redis分布式缓存,配合互斥锁或逻辑过期机制。第四层是DAO层的限流。第五层是数据库自身的连接池保护和索引优化。

本地缓存这一层经常被低估。Caffeine的性能比Redis高出两个数量级,因为它没有网络开销,直接操作堆内存。把访问频率最高的那1%的数据缓存在本地,能挡掉大量Redis请求。但要注意本地缓存的过期时间要设得比Redis短,否则多实例之间的数据一致性问题会被放大。

监控和告警是防护体系的眼睛。缓存命中率突然下降、数据库活跃连接数飙升、某个接口的响应时间变长,这些都可能是穿透或击穿的前兆。把缓存命中率、连接池使用率、接口P99延迟这些指标接入Prometheus和Grafana,设置合理的告警阈值,才能在问题恶化之前介入。

最后要说的是,穿透和击穿的防护不是一劳永逸的。业务规模在增长,数据量在膨胀,曾经有效的方案可能会逐渐失效。比如布隆过滤器的误判率会随着数据量增加而上升,需要定期评估是否需要扩容位数组。热点数据也在变化,昨天无人问津的商品今天可能因为一条短视频突然爆火。防护体系需要持续迭代,结合业务特点不断调整参数和策略,才能在高并发场景下保持数据库的稳定。