Redis缓存穿透的本质是查询一个根本不存在的数据,由于缓存不会存储空值,每次请求都会穿过缓存直接打到数据库上。当攻击者或程序缺陷持续制造大量不存在key的请求时,数据库连接池和CPU资源会被迅速耗尽,最终拖垮整个后端服务。解决这个问题不能只靠单一手段,需要从请求入口、缓存层、数据层三个维度同时设防。
布隆过滤器前置拦截
布隆过滤器是应对缓存穿透最直接的方案。它的原理是用一个足够大的位数组和多个哈希函数,在数据写入时将所有合法key映射到位数组中。请求进来时先经过布隆过滤器判断key是否可能存在,如果判断为不存在,直接返回空结果,根本不会进入缓存和数据库。实现时需要注意两个关键参数:预期数据量n和可接受的误判率p。误判率越低,需要的位数组越大,哈希函数越多。以Java中的Guava库为例:
BloomFilterfilter = BloomFilter.create( Funnels.stringFunnel(Charset.defaultCharset()), 1000000, // 预期插入数量 0.001 // 误判率 ); // 数据初始化时写入所有合法key filter.put("user:10001"); filter.put("product:20001"); // 请求入口判断 if (!filter.mightContain(key)) { return null; }
布隆过滤器有个必须注意的问题:它不支持删除操作。如果业务中存在key被删除或过期的情况,过滤器中的标记不会同步消失,会导致后续合法请求被误拦截。对于这种场景,可以使用计数布隆过滤器或者定期重建过滤器。重建时从数据库全量扫描合法key,写入新的过滤器实例,再用原子引用切换。另外,布隆过滤器本身要放在应用内存或独立的Redis中,避免每次请求都重新加载。
缓存空值标记
对于查询结果为空的key,可以在Redis中写入一个短过期时间的空值标记,比如空字符串或特殊占位符。这样后续相同key的请求就能命中缓存直接返回,不会继续穿透到数据库。过期时间一般设置在30秒到5分钟之间,既能挡住密集的穿透请求,又不会因为缓存太多空值占用大量内存。实现逻辑如下:
public String getData(String key) {
String value = redis.get(key);
if (value != null) {
return value.equals("NULL_MARK") ? null : value;
}
String dbValue = database.query(key);
if (dbValue == null) {
redis.setex(key, 180, "NULL_MARK");
return null;
}
redis.setex(key, 3600, dbValue);
return dbValue;
}
空值缓存方案简单有效,但有两个局限。第一,如果攻击者不断变化key,比如在key后面追加随机后缀,空值缓存就失效了,因为每个新key都会穿透一次。第二,大量空值标记会占用Redis内存,需要结合内存淘汰策略和监控告警来控制。实际项目中,空值缓存适合作为兜底手段,配合布隆过滤器一起使用。
请求参数合法性校验
很多穿透请求其实在入口处就能通过参数校验拦截掉。比如用户ID要求是正整数,但请求中传入负数、0或者超长字符串,这些明显不合法的参数根本不需要查询缓存和数据库。在Controller层或服务层增加统一的参数校验器,对ID范围、格式、长度做严格限制。对于分页查询,限制pageSize的最大值,对于模糊搜索,限制关键词长度和特殊字符。这种校验成本极低,却能过滤掉大量恶意构造的无效请求。
限流与熔断保护
即使有布隆过滤器和空值缓存,极端情况下仍可能有大量请求穿透到数据库。这时需要在数据访问层增加限流和熔断机制。可以使用令牌桶或漏桶算法对数据库查询接口限流,超过阈值的请求直接返回降级结果。熔断器则监控数据库查询的失败率和响应时间,当数据库出现异常时自动打开熔断,后续请求快速失败,等待一段时间后进入半开状态尝试恢复。以Hystrix或Resilience4j为例,配置合理的熔断阈值和超时时间,能有效防止数据库被拖垮。
异步加载与请求合并
对于热点key被大量并发请求穿透的场景,可以使用互斥锁或分布式锁,只让一个请求去数据库加载数据,其他请求等待结果。在Redis中通过SETNX实现简单的分布式锁:
public String getDataWithLock(String key) {
String value = redis.get(key);
if (value != null) {
return value;
}
String lockKey = "lock:" + key;
boolean locked = redis.setnx(lockKey, "1", 10);
if (locked) {
try {
value = database.query(key);
if (value != null) {
redis.setex(key, 3600, value);
} else {
redis.setex(key, 180, "NULL_MARK");
}
return value;
} finally {
redis.del(lockKey);
}
} else {
// 等待持锁者完成加载后重试
Thread.sleep(50);
return getDataWithLock(key);
}
}
请求合并则是在应用层将短时间内相同的key查询合并成一次批量查询,减少数据库交互次数。这种方式适合读多写少、key重复度高的场景,实现时可以用一个本地队列和定时任务来聚合请求。
数据层兜底策略
数据库层面也需要做防护。对于查询不存在数据的SQL,确保索引覆盖查询条件,避免全表扫描。可以为经常被穿透的查询字段建立联合索引,或者使用数据库的查询缓存。更彻底的方案是在数据库前加一层只读副本或专门的查询服务,将穿透压力隔离在主库之外。对于极端情况,可以配置数据库连接池的最大活跃连接数和等待超时,避免连接被耗尽后影响其他正常业务。
监控与告警体系
防护策略部署后,必须建立对应的监控指标。重点监控Redis的命中率、空值缓存数量、布隆过滤器误判率、数据库慢查询数量和连接池使用率。当缓存穿透发生时,通常会看到Redis命中率突然下降,数据库查询量激增,空值key数量异常增长。设置合理的告警阈值,比如缓存命中率低于80%持续5分钟就触发告警,数据库连接池使用率超过90%立即通知。通过监控数据可以快速定位穿透的key模式,进而调整防护参数或封禁攻击来源。
综合优化建议
实际生产环境中,单一的防护手段很难完全杜绝缓存穿透。建议按照以下优先级组合使用:第一层用参数校验拦截明显非法的请求;第二层用布隆过滤器过滤不存在的key;第三层用空值缓存兜底漏网之鱼;第四层用分布式锁和限流保护数据库;第五层用监控告警及时发现异常。每一层都有各自的成本和局限,需要根据业务的数据量、key分布特征和访问模式来权衡。例如,数据量在百万级以内且key格式规范,布隆过滤器可以做到很低的误判率;数据量上亿且key频繁变动,则要重点依赖空值缓存和限流。定期复盘穿透事件,分析攻击key的生成规律,持续优化防护规则,才能让Redis真正成为数据库的坚实屏障。
