找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

2929

积分

0

好友

397

主题
发表于 1 小时前 | 查看: 4| 回复: 0

面向高并发业务的完整缓存穿透治理方案。本文不只讨论“如何避免查不存在的数据”,而是从系统设计、工程实现、监控治理、容量评估到生产落地,构建一套真正可上线、可演进、可观测的防护体系。

一、引言:为什么缓存穿透是线上系统的高危问题

在绝大多数业务系统中,缓存的目标都很明确:用更低延迟、更低成本承接大部分读请求,把数据库从高频访问中解放出来。

但很多系统只缓存“存在的数据”,却没有处理“不存在的数据”。一旦有人持续查询不存在的 Key,请求就会绕过缓存,直接打到数据库。这就是缓存穿透。

缓存穿透危险的地方在于,它并不是“缓存失效”那么简单,而是以下几个问题的叠加:

  • 缓存层对非法或不存在请求没有形成有效拦截
  • 数据库被迫承担本不应该承担的随机读压力
  • 连接池、线程池、下游依赖被逐步拖垮
  • 监控上经常只表现为命中率下降和空查询上升,具有隐蔽性
  • 在恶意攻击场景下,攻击成本极低,破坏成本极高

很多团队第一次遇到缓存穿透,往往是在以下场景:

  • 商品详情页被爬虫扫不存在的商品 ID
  • 用户中心被撞库脚本批量试探 UID
  • 内容平台被随机文章 ID、评论 ID 扫描
  • 接口参数未校验,大量非法主键直接进入数据库查询

所以,缓存穿透治理不是一个“Redis 小技巧”,而是高并发架构中数据库保护体系的重要组成部分。

二、先厘清边界:缓存穿透、缓存击穿、缓存雪崩

这三个概念经常被混用,但它们对应的问题完全不同。

问题类型 本质 典型触发条件 影响范围 核心治理方向
缓存穿透 查不存在的数据 非法参数、恶意扫描、空洞数据 某类 key 持续绕过缓存 前置拦截、空值缓存、布隆过滤器
缓存击穿 热点 key 失效瞬间并发回源 热点数据过期、瞬时高并发 单个热点 key singleflight、互斥锁、逻辑过期
缓存雪崩 大量 key 同时失效 批量过期、Redis 故障、重启 大范围请求回源 TTL 打散、多级缓存、限流降级

一句话区分:

  • 穿透:数据本来就不存在
  • 击穿:数据存在,但热点缓存突然没了
  • 雪崩:大量缓存同时不可用

很多线上事故并不是单一问题,而是“穿透 + 击穿 + 限流缺失 + 数据库保护不足”的复合故障。因此本文讨论缓存穿透时,会把它放进完整架构中统一分析。

三、缓存穿透的本质:为什么它会绕过缓存层

3.1 标准读链路

一个典型的缓存读链路如下:

请求 -> 本地缓存 -> Redis -> 数据库 -> 回填缓存 -> 返回结果

正常情况下:

  • 缓存命中,快速返回
  • 缓存未命中,查询数据库
  • 数据存在,写入缓存

问题就出在“数据不存在”这条分支。

3.2 穿透链路

请求一个不存在的 ID
-> 本地缓存 miss
-> Redis miss
-> 数据库查询为空
-> 没有缓存任何结果
-> 下一次相同请求继续重复上述流程

本质上,缓存穿透是“负结果没有被缓存或拦截”。

如果用更架构化的语言来描述,缓存穿透代表:

  • 缓存层只承载正向存在性结果
  • 但系统缺少负向存在性结果的表达机制
  • 导致查询链路在空洞数据区域无限回源

这个“负向存在性结果”可以有不同实现方式:

  • 空对象缓存
  • 布隆过滤器
  • 参数范围校验
  • 业务字典校验
  • 网关限流与风控

本质都是在回答一个问题:

在真正访问数据库之前,我们能不能更早、更便宜地判断“这个请求大概率不该回源”?

四、缓存穿透的成因分析

缓存穿透不只有“恶意攻击”这一种来源,生产上至少有六类常见原因。

4.1 非法参数直接进入查询链路

例如:

  • 商品 ID 小于等于 0
  • 用户 ID 超出业务编号范围
  • 订单号格式不合法
  • 查询维度缺少租户或分片字段

这类问题最不应该进入 Redis,更不应该进入数据库,但在很多系统里,Controller 到 DAO 之间完全没有参数治理。

4.2 大量查询真实不存在的数据

例如:

  • 用户输入不存在的券码
  • 内容详情查询已经删除的资源
  • 下游系统回调了过期 ID

这类请求不是攻击,却同样会形成穿透。

4.3 恶意扫描与爬虫攻击

常见模式:

  • 顺序枚举主键
  • 随机生成超大 ID
  • 通过多 IP 分布式发起空查询
  • 利用接口未鉴权或轻鉴权进行探测

4.4 缓存与业务主数据不同步

例如:

  • 数据刚删除,缓存也删除了
  • 但请求仍然持续访问该资源
  • 删除后的空洞区域变成持续穿透点

4.5 布隆过滤器数据陈旧

如果系统使用布隆过滤器,但没有做好初始化、增量更新和重建机制,也会带来问题:

  • 新数据未入过滤器,导致误拦截
  • 删除数据仍在过滤器中,导致放行空查询
  • 集群切换后过滤器丢失

4.6 业务扩展后,原有缓存设计不再成立

例如从单体走向微服务后:

  • 原本只按 id 缓存,后来引入 tenantId + id
  • 原本按单库主键查,后来按地域、版本、渠道查
  • 原本热点均匀,后来活动期间某类空查询陡增

很多穿透问题不是技术方案不存在,而是业务模型变了、缓存模型没跟上。

五、事故视角:缓存穿透如何一步步拖垮系统

下面用一个电商商品详情场景看真实链路。

5.1 业务场景

  • 商品详情接口 QPS 峰值 3 万
  • Redis 命中率平时 97%
  • 数据库采用 MySQL 主从
  • 商品主键为自增 long

攻击者构造大量不存在的商品 ID:

/api/products/9000000001
/api/products/9000000002
/api/products/9000000003
...

5.2 故障演进路径

第一阶段:Redis 未命中率升高

  • 因为查询的 key 根本不存在
  • Redis 无法承接这些请求

第二阶段:数据库空查询暴涨

  • 大量 select ... where id = ?
  • 单条 SQL 也许不慢,但总量极大

第三阶段:连接池被打满

  • 应用线程阻塞等待连接
  • 上游接口响应时间抖动

第四阶段:线程池堆积和超时扩散

  • Tomcat/Netty 工作线程被占用
  • 依赖服务调用超时
  • 熔断、重试、回退进一步放大流量

第五阶段:数据库高负载引发全站性能恶化

  • 正常业务请求也拿不到资源
  • 从“单接口问题”演变成“全站事故”

这就是为什么缓存穿透的治理目标,从来不只是“省几次 DB 查询”,而是保护整个系统的承压边界。

六、治理原则:生产级方案的设计目标

真正能在生产上长期稳定运行的缓存穿透防护,通常遵循以下原则:

6.1 前置拦截优于回源后补救

能在网关、参数层、存在性过滤层拦掉的请求,不要让它进入数据库。

6.2 分层治理而不是单点依赖

不要迷信某一个方案,例如:

  • 只靠空值缓存,不够
  • 只靠布隆过滤器,也不够
  • 只靠限流,更不够

必须是组合拳。

6.3 接受概率性结构,但必须有兜底

布隆过滤器速度快、内存省,但有误判率;因此它适合做“前置放行判断”,不适合作为唯一真相来源。

6.4 把数据库保护作为核心目标

指标上真正要看的不是“是否用了 Bloom Filter”,而是:

  • 空查询是否下降
  • 数据库 QPS 是否被有效保护
  • 在攻击下系统是否还能优雅退化

6.5 方案必须可重建、可观测、可动态调优

如果你的防护组件上线后无法回答下面这些问题,它就还不算生产级:

  • 过滤器当前容量和误判率是多少
  • 空对象缓存的命中占比是多少
  • 某类 key 的穿透来源是什么
  • 过滤器何时初始化、何时重建
  • Redis 故障时系统如何降级

七、方案对比:缓存空值、布隆过滤器、参数校验、限流分别解决什么问题

7.1 缓存空值

原理:

  • 当数据库查询为空时,不是直接返回,而是把“空结果”以特殊占位对象写入缓存
  • 相同请求再次到来时,直接命中空对象,不再回源

优点:

  • 实现简单
  • 结果准确,没有误判
  • 对重复查询同一空 key 效果很好

缺点:

  • 无法阻止“每次都换一个新不存在 key”的攻击
  • 会占用部分缓存空间
  • 需要处理空值序列化、TTL 和语义表达问题

适用:

  • 空洞 key 重复访问较多
  • 业务删除后有持续访问
  • 需要强一致的负结果表达

7.2 布隆过滤器

原理:

  • 用位图和多个哈希函数记录“一个元素可能存在”
  • 查询时如果判定“不存在”,则一定不存在
  • 如果判定“存在”,则只是“可能存在”

优点:

  • 内存占用极小
  • 查询极快
  • 非常适合做前置拦截

缺点:

  • 有误判率
  • 标准 Bloom 不支持删除
  • 需要初始化和增量维护

适用:

  • 海量 ID 存在性判断
  • 高 QPS 前置拦截
  • 对误判可接受,但不能接受大量回源

7.3 参数与业务规则校验

原理:

  • 利用业务边界直接过滤不合法请求

例如:

  • ID 必须大于 0
  • 用户名必须符合格式
  • 商品类目必须在字典中
  • 租户和渠道必须匹配

优点:

  • 成本最低
  • 没有误判
  • 能在最前面截断无效流量

缺点:

  • 只能挡住显式非法请求
  • 挡不住“格式合法但实际不存在”的请求

7.4 网关限流、风控、黑白名单

原理:

  • 从来源维度约束异常流量

适用:

  • 恶意攻击
  • 某一类 User-Agent、IP 段、设备指纹异常

优点:

  • 保护范围更靠前
  • 对整个系统都有效

缺点:

  • 不是业务存在性判断
  • 不能替代缓存层防护

7.5 最佳实践结论

生产上推荐方案不是“四选一”,而是分层组合:

网关限流/风控
    -> 参数合法性校验
    -> Bloom Filter 存在性判断
    -> Redis 正常缓存
    -> Redis 空对象缓存
    -> 单飞/互斥回源
    -> 数据库

八、布隆过滤器原理与容量设计

很多文章只说“Bloom 很省内存”,却不解释怎么估算容量。生产落地时,这一步必须算清楚。

8.1 基本原理

布隆过滤器由两部分组成:

  • 一个长度为 m 的位数组
  • k 个独立哈希函数

插入元素时:

  • 对元素做 k 次哈希
  • 把得到的 k 个位置都置为 1

判断元素是否存在时:

  • 再做 k 次哈希
  • 如果有任一位为 0,则一定不存在
  • 如果全部为 1,则可能存在

8.2 关键特性

  • 不存在假阴性:判断不存在,就一定不存在
  • 存在假阳性:判断存在,只是可能存在

这正适合缓存穿透场景:

  • 过滤掉大量确定不存在的请求
  • 少量“误以为存在”的请求再交给 Redis/DB 兜底

8.3 参数公式

假设:

  • 预计元素数量为 n
  • 可接受误判率为 p

则位图大小 m 和哈希函数个数 k 近似为:

m = - (n * ln p) / (ln 2)^2
k = (m / n) * ln 2

8.4 示例估算

若系统需要容纳 1000 万个商品 ID,误判率设为 0.1%:

n = 10,000,000
p = 0.001

计算结果大约为:

  • 位数组大小约 1.7e8 bit
  • 折合约 20 MB
  • 哈希函数个数约 10

这意味着:

  • 只用 20MB 左右内存
  • 就能为 1000 万主键提供存在性判断
  • 对比直接缓存所有空值,内存收益非常可观

8.5 误判率如何选择

常见建议:

  • 1e-2:适合一般业务
  • 1e-3:适合生产常用配置
  • 1e-4:适合对空查询特别敏感的场景

误判率越低:

  • 位图越大
  • 内存越高
  • 哈希次数略增

实际工程中,一般优先在 1e-31e-4 之间取值。

九、生产级总体架构:不是一个组件,而是一条防线

下面给出一套适合中大型系统的缓存穿透治理架构。

                ┌────────────────────┐
请求进入  ----> │ API Gateway         │
                │ 限流/鉴权/黑名单    │
                └─────────┬──────────┘
                          │
                ┌─────────▼──────────┐
                │ 参数校验层          │
                │ ID范围/格式/租户    │
                └─────────┬──────────┘
                          │
                ┌─────────▼──────────┐
                │ 本地热点缓存        │
                │ Caffeine            │
                └─────────┬──────────┘
                          │
                ┌─────────▼──────────┐
                │ Redis缓存层         │
                │ 正常值 + 空对象     │
                └─────────┬──────────┘
                          │
                ┌─────────▼──────────┐
                │ Bloom Filter        │
                │ 存在性拦截          │
                └─────────┬──────────┘
                          │
                ┌─────────▼──────────┐
                │ singleflight/互斥锁 │
                │ 防并发回源放大      │
                └─────────┬──────────┘
                          │
                ┌─────────▼──────────┐
                │ MySQL / ES / 下游   │
                └────────────────────┘

9.1 每一层各自解决什么问题

  • 网关:挡掉显著异常流量
  • 参数校验:挡掉显式无效请求
  • 本地缓存:降低 Redis 压力,承接热点
  • Redis 正常缓存:承接主流存在数据
  • 空对象缓存:挡住重复空查询
  • Bloom:挡住海量随机不存在请求
  • singleflight:防止缓存 miss 时并发回源击穿 DB
  • 数据库:最终真相源

9.2 为什么 Bloom 放在 Redis 后面而不是前面

两种顺序都有人用,但生产里更推荐:

本地缓存 -> Redis -> Bloom -> DB

原因:

  • Redis 命中时无需经过 Bloom
  • 只有缓存 miss 才做存在性判断
  • 减少不必要的过滤器访问

如果 Bloom 和 Redis 在同一实例中,性能差异可能不明显;但从链路语义上,先查缓存、再判是否值得回源更自然。

十、核心实现一:空对象缓存的生产级设计

很多示例代码把“查不到就缓存 null”写得很简单,但真正上线要处理四个问题:

  • null 无法表达语义
  • 序列化与反序列化问题
  • TTL 如何设置
  • 如何区分“真的不存在”和“系统异常”

10.1 统一缓存对象设计

package com.example.cache;

import java.io.Serializable;
import java.time.LocalDateTime;

public class CacheEnvelope<T> implements Serializable {

    private boolean empty;
    private T data;
    private LocalDateTime expireAt;
    private String version;

    public static <T> CacheEnvelope<T> hit(T data, LocalDateTime expireAt, String version) {
        CacheEnvelope<T> envelope = new CacheEnvelope<>();
        envelope.empty = false;
        envelope.data = data;
        envelope.expireAt = expireAt;
        envelope.version = version;
        return envelope;
    }

    public static <T> CacheEnvelope<T> empty(LocalDateTime expireAt, String version) {
        CacheEnvelope<T> envelope = new CacheEnvelope<>();
        envelope.empty = true;
        envelope.expireAt = expireAt;
        envelope.version = version;
        return envelope;
    }

    public boolean isEmpty() {
        return empty;
    }

    public T getData() {
        return data;
    }

    public LocalDateTime getExpireAt() {
        return expireAt;
    }

    public String getVersion() {
        return version;
    }
}

设计要点:

  • empty 明确表达“空对象”
  • expireAt 支持逻辑过期扩展
  • version 预留缓存结构升级能力

10.2 TTL 策略

不要让空对象和正常对象用同样的过期时间。

推荐:

  • 正常数据 TTL:30 分钟到 2 小时
  • 空对象 TTL:30 秒到 5 分钟
  • 两者都加随机抖动,避免同一时刻批量失效

原因:

  • 正常数据通常稳定,适合较长缓存
  • 空对象是负结果,只适合短缓存
  • 如果对象后来被创建,过长的空缓存会带来可见延迟

10.3 什么时候不应该缓存空对象

以下场景要谨慎:

  • 数据刚创建、传播有延迟时
  • 最终一致性链路中,短时间内“现在不存在、马上存在”
  • 对实时性极高的交易核心对象

此时可以:

  • 缩短空对象 TTL
  • 仅对部分业务缓存空对象
  • 增加主动失效通知

十一、核心实现二:基于 Redisson 的 Bloom Filter

以下是一个接近生产的 Spring Boot 实现。

11.1 依赖示例

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.27.2</version>
</dependency>

11.2 配置

spring:
  data:
    redis:
      host: 127.0.0.1
      port: 6379
      password: your_password

cache-protection:
  bloom:
    product:
      name: bloom:product:id
      expected-insertions: 10000000
      false-probability: 0.001
  ttl:
    product: 1800s
    product-empty: 90s
  lock:
    product: 3s

11.3 初始化代码

package com.example.cache.config;

import jakarta.annotation.PostConstruct;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
public class ProductBloomInitializer {

    private final RedissonClient redissonClient;

    @Value("${cache-protection.bloom.product.name}")
    private String bloomName;

    @Value("${cache-protection.bloom.product.expected-insertions}")
    private long expectedInsertions;

    @Value("${cache-protection.bloom.product.false-probability}")
    private double falseProbability;

    public ProductBloomInitializer(RedissonClient redissonClient) {
        this.redissonClient = redissonClient;
    }

    @PostConstruct
    public void init() {
        RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter(bloomName);
        bloomFilter.tryInit(expectedInsertions, falseProbability);
    }
}

说明:

  • tryInit 只会在未初始化时生效
  • 正式环境建议把“初始化”和“全量灌装”拆开,由运维任务或数据作业执行
  • 应用启动时只做幂等检查,不要全量扫大表

11.4 全量灌装与增量维护

布隆过滤器不是“初始化完就不管了”,需要解决两个问题:

  • 存量数据怎么灌进去
  • 新增数据怎么同步进去

推荐策略:

  • 全量初始化:离线任务扫描主表,批量写入 Bloom
  • 增量更新:业务新增成功后,异步写入 Bloom
  • 定期重建:按周或按月重建,修正历史漂移

新增写入示例:

package com.example.product.service;

import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class ProductWriteService {

    private final ProductRepository productRepository;
    private final RedissonClient redissonClient;

    @Value("${cache-protection.bloom.product.name}")
    private String bloomName;

    public ProductWriteService(ProductRepository productRepository, RedissonClient redissonClient) {
        this.productRepository = productRepository;
        this.redissonClient = redissonClient;
    }

    @Transactional
    public Long createProduct(CreateProductCommand command) {
        Product product = Product.create(command);
        productRepository.save(product);

        RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter(bloomName);
        bloomFilter.add(product.getId());

        return product.getId();
    }
}

这里有一个工程细节:

  • Bloom 是“只增不减”更安全
  • 删除商品后,一般不建议立即从 Bloom 删除
  • 因为标准 Bloom 不支持删除,强删需要 Counting Bloom 或重建机制

生产里更常见的做法是:

  • 删除后保留 Bloom 中的存在记录
  • Redis 层通过空对象缓存承接已删除商品的访问
  • 周期性重建 Bloom,清掉无效历史数据

十二、核心实现三:单飞回源,避免并发 miss 放大数据库压力

缓存穿透与缓存击穿经常同时出现。

即使一个请求通过了 Bloom,如果 Redis miss,而这时并发很高,那么大量线程仍可能一起回源数据库。因此必须增加“单飞”或“互斥重建”能力。

12.1 目标

对于同一个 key:

  • 只允许一个线程真正回源数据库
  • 其他线程等待结果或快速失败

12.2 基于 Redisson 锁的实现

package com.example.product.service;

import com.example.cache.CacheEnvelope;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.concurrent.TimeUnit;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

@Service
public class ProductReadService {

    private static final String CACHE_KEY_PREFIX = "product:";
    private static final String LOCK_KEY_PREFIX = "lock:product:";

    private final StringRedisTemplate redisTemplate;
    private final ProductRepository productRepository;
    private final RedissonClient redissonClient;
    private final ObjectMapper objectMapper;

    @Value("${cache-protection.bloom.product.name}")
    private String bloomName;

    public ProductReadService(StringRedisTemplate redisTemplate,
                              ProductRepository productRepository,
                              RedissonClient redissonClient,
                              ObjectMapper objectMapper) {
        this.redisTemplate = redisTemplate;
        this.productRepository = productRepository;
        this.redissonClient = redissonClient;
        this.objectMapper = objectMapper;
    }

    public ProductDTO getProduct(Long productId) {
        validateProductId(productId);

        String cacheKey = CACHE_KEY_PREFIX + productId;
        CacheEnvelope<ProductDTO> cached = getFromCache(cacheKey);
        if (cached != null) {
            return cached.isEmpty() ? null : cached.getData();
        }

        RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter(bloomName);
        if (!bloomFilter.contains(productId)) {
            cacheEmpty(cacheKey);
            return null;
        }

        String lockKey = LOCK_KEY_PREFIX + productId;
        RLock lock = redissonClient.getLock(lockKey);
        boolean locked = false;
        try {
            locked = lock.tryLock(100, 3000, TimeUnit.MILLISECONDS);
            if (!locked) {
                CacheEnvelope<ProductDTO> retryCached = getFromCache(cacheKey);
                return retryCached == null || retryCached.isEmpty() ? null : retryCached.getData();
            }

            CacheEnvelope<ProductDTO> doubleCheck = getFromCache(cacheKey);
            if (doubleCheck != null) {
                return doubleCheck.isEmpty() ? null : doubleCheck.getData();
            }

            Product product = productRepository.findById(productId);
            if (product == null) {
                cacheEmpty(cacheKey);
                return null;
            }

            ProductDTO dto = ProductDTO.from(product);
            cacheData(cacheKey, dto, Duration.ofMinutes(30));
            return dto;
        } catch (InterruptedException ex) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("Interrupted while acquiring product lock", ex);
        } finally {
            if (locked && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }

    private void validateProductId(Long productId) {
        if (productId == null || productId <= 0) {
            throw new IllegalArgumentException("Invalid productId");
        }
    }

    private CacheEnvelope<ProductDTO> getFromCache(String key) {
        try {
            String json = redisTemplate.opsForValue().get(key);
            if (json == null || json.isBlank()) {
                return null;
            }
            return objectMapper.readValue(json, new TypeReference<CacheEnvelope<ProductDTO>>() {});
        } catch (Exception ex) {
            return null;
        }
    }

    private void cacheData(String key, ProductDTO data, Duration ttl) {
        try {
            CacheEnvelope<ProductDTO> envelope = CacheEnvelope.hit(
                    data,
                    LocalDateTime.now().plusSeconds(ttl.getSeconds()),
                    "v1"
            );
            redisTemplate.opsForValue().set(key, objectMapper.writeValueAsString(envelope), ttl);
        } catch (Exception ex) {
            // 生产中建议记录日志与指标,不影响主流程
        }
    }

    private void cacheEmpty(String key) {
        try {
            Duration ttl = Duration.ofSeconds(90);
            CacheEnvelope<ProductDTO> envelope = CacheEnvelope.empty(
                    LocalDateTime.now().plusSeconds(ttl.getSeconds()),
                    "v1"
            );
            redisTemplate.opsForValue().set(key, objectMapper.writeValueAsString(envelope), ttl);
        } catch (Exception ex) {
            // 生产中建议记录日志与指标,不影响主流程
        }
    }
}

这段代码做了四件重要的事:

  • 参数前置校验
  • 缓存 miss 后走 Bloom 拦截
  • 回源前加分布式系统锁,防止并发放大
  • 对空查询缓存短 TTL 空对象

这才是“可以上线”的基本形态,而不是只写一个 if (obj == null)

十三、核心实现四:本地缓存 + Redis 的两级缓存设计

高并发服务中,仅靠 Redis 还不够。因为 Redis 虽快,但仍然是网络调用;热点 key 极多时,Redis 自身也会成为瓶颈。

因此推荐两级缓存:

  • L1:Caffeine 本地缓存
  • L2:Redis 分布式缓存

读链路变为:

请求 -> Caffeine -> Redis -> Bloom -> DB

13.1 为什么本地缓存对穿透治理也有价值

它不仅能挡住热点存在数据,也能承接热点空对象:

  • 某个不存在的商品 ID 被重复访问
  • Redis 中已有空对象
  • 读取后可回填到本地缓存
  • 后续同机请求无需再访问 Redis

13.2 使用建议

  • 正常对象本地 TTL:30 秒到 2 分钟
  • 空对象本地 TTL:10 秒到 30 秒
  • 本地缓存容量按实例内存预算严格限制

注意:

  • 本地缓存不是一致性缓存
  • 它承接的是热点与短期抖动,不承担强一致职责

十四、完整读流程:生产级伪代码

下面给出一份完整的读路径伪代码,便于在任意语言中迁移实现。

public ProductDTO queryProduct(Long productId) {
    // 1. 参数校验
    if (productId == null || productId <= 0) {
        throw new BadRequestException("invalid productId");
    }

    // 2. 本地缓存
    CacheEnvelope<ProductDTO> local = localCache.get(productId);
    if (local != null) {
        return local.isEmpty() ? null : local.getData();
    }

    // 3. Redis缓存
    CacheEnvelope<ProductDTO> remote = redisCache.get(productId);
    if (remote != null) {
        localCache.put(productId, remote);
        return remote.isEmpty() ? null : remote.getData();
    }

    // 4. Bloom快速判断
    if (!bloom.mightContain(productId)) {
        CacheEnvelope<ProductDTO> empty = CacheEnvelope.empty(nowPlusSeconds(30), "v1");
        localCache.put(productId, empty);
        redisCache.setEmpty(productId, empty, 90);
        return null;
    }

    // 5. key级单飞
    return singleflight.execute("product:" + productId, () -> {
        CacheEnvelope<ProductDTO> recheck = redisCache.get(productId);
        if (recheck != null) {
            localCache.put(productId, recheck);
            return recheck.isEmpty() ? null : recheck.getData();
        }

        Product product = repository.findById(productId);
        if (product == null) {
            CacheEnvelope<ProductDTO> empty = CacheEnvelope.empty(nowPlusSeconds(90), "v1");
            redisCache.setEmpty(productId, empty, 90);
            localCache.put(productId, empty);
            return null;
        }

        ProductDTO dto = ProductDTO.from(product);
        CacheEnvelope<ProductDTO> hit = CacheEnvelope.hit(dto, nowPlusMinutes(30), "v1");
        redisCache.set(productId, hit, 1800 + random(0, 300));
        localCache.put(productId, hit);
        return dto;
    });
}

这个流程体现了三个关键思想:

  • 先过滤,再回源
  • 回源只允许少数线程进行
  • 正向结果和负向结果都要缓存

十五、真实业务场景拆解

15.1 场景一:商品详情页

特点:

  • QPS 高
  • 商品 ID 海量
  • 下架、删除、预发商品较多

推荐方案:

  • 商品 ID 放入 Bloom
  • 已删除商品用空对象短缓存
  • 热门商品走本地缓存 + Redis
  • 活动期叠加网关限流与验证码策略

15.2 场景二:用户主页

特点:

  • 涉及隐私与安全
  • 容易被撞库、探测 UID

推荐方案:

  • 网关按 IP、设备指纹、账号维度限流
  • 对 UID 做范围和格式校验
  • 使用 Bloom 拦截不存在 UID
  • 对敏感接口增加鉴权后查询

15.3 场景三:已删除内容页

特点:

  • 删除后仍可能被搜索引擎、旧链接持续访问

推荐方案:

  • 删除事件触发缓存失效
  • Redis 写入短 TTL 空对象
  • Bloom 保持“可能存在”,等待周期性重建

15.4 场景四:多租户系统

特点:

  • 同一个 id 在不同租户下语义不同

推荐方案:

  • 缓存 key 必须包含租户维度
  • Bloom 元素也要用复合键,例如 tenantId:id
  • 参数校验层必须校验租户合法性

这是生产中最容易踩的坑之一。很多团队只把 id 放进 Bloom,结果租户隔离直接失效。

十六、高并发下的工程化升级

缓存穿透不是只写几个类就结束,真正的难点在工程化。

16.1 线程池与连接池保护

建议同时配置:

  • Web 线程池最大并发上限
  • DB 连接池最大连接数与超时
  • Redis 客户端连接池
  • 接口级别超时控制

目标不是“扛住所有流量”,而是“在异常流量下优雅失败”。

16.2 回源限流

即使 Bloom 误判或配置异常,也不能让数据库无限吃流量。

建议增加:

  • 接口级回源限速
  • 某类 key 的 DB 查询并发阈值
  • 超阈值时返回兜底页、空结果或系统繁忙

16.3 TTL 打散

无论正常缓存还是空对象缓存,都应该增加随机抖动。

例如:

商品缓存 TTL = 1800s + random(0, 300)
空对象 TTL = 90s + random(0, 20)

避免大量 key 在同一秒过期,形成局部雪崩。

16.4 热 key 隔离

某些不存在 key 在攻击期间会变成“空热点 key”。

建议:

  • 将热点空 key 放进本地缓存
  • 必要时将极端 key 单独打标监控
  • 对异常 key 做临时黑名单或更长负缓存

16.5 降级兜底

当 Redis 不可用时怎么办?

如果没有降级,系统会直接退化成“所有请求访问数据库”。

建议:

  • 本地缓存继续承接短期热点
  • 对高风险接口启用快速失败
  • 关闭非核心查询
  • 临时提高网关限流强度

十七、分布式与微服务场景下的设计要点

17.1 Bloom 应该由谁维护

推荐模式:

  • 由业务服务在写路径上做增量写入
  • 由独立离线任务做全量构建与周期重建

不要让每个消费方都各自维护一份不同版本的过滤器,否则数据一致性和容量评估都会失控。

17.2 多副本服务如何避免重复回源

单机 synchronized 没用,因为请求会落到不同实例。

因此需要:

  • 分布式锁
  • 或基于 singleflight 的 key 聚合组件
  • 或通过消息/异步重建让写路径承担更多职责

17.3 多机房部署

跨机房下要考虑:

  • Bloom 是否单中心还是多中心
  • Redis 是否就近访问
  • 机房切换后过滤器是否已同步

推荐:

  • 过滤器数据与业务主数据同地域构建
  • 机房内优先本地 Redis/Bloom
  • 跨地域只做灾备,不做主链路频繁访问

17.4 分片数据库场景

如果主键不是全局连续,而是分库分表后的复合路由:

  • Bloom 的元素不能只放 id
  • 应放 bizType + tenantId + shardKey + id

否则“存在性判断”本身就不准确。

十八、数据一致性:新增、修改、删除如何影响防护体系

18.1 新增

顺序建议:

  1. 写数据库
  2. 事务成功后写 Bloom
  3. 删除或更新缓存

如果先写 Bloom 再写数据库,事务失败会导致过滤器提前放行,增加空回源。

18.2 修改

缓存穿透的关键不在修改,而在修改后缓存是否正确失效:

  • 更新数据库成功后删除缓存
  • 必要时异步重建热点缓存

18.3 删除

删除最容易产生穿透。

建议:

  1. 数据库标记删除或物理删除
  2. 删除正常缓存
  3. 写入短 TTL 空对象缓存
  4. 保留 Bloom 标记到下一轮重建

这是线上最稳妥的做法。

18.4 为什么删除时不建议立即“移除 Bloom”

因为标准 Bloom 不支持删除,强行支持会引入以下复杂度:

  • 需要 Counting Bloom
  • 内存成本更高
  • 并发更新复杂
  • 误删风险更大

大多数业务没必要为“删除实时生效”引入这么高的复杂度,短 TTL 空对象 + 定期重建即可。

十九、监控与可观测性:没有指标就没有生产级治理

缓存穿透治理最怕一种情况:方案上线了,但团队并不知道它有没有生效。

至少要建设以下指标。

19.1 核心指标

  • Redis 总命中率
  • 空对象缓存命中次数
  • Bloom 拦截次数
  • Bloom 放行后 DB 空查询次数
  • DB 查询 QPS
  • DB 空查询占比
  • 分布式锁竞争次数
  • 接口 P95/P99 延迟
  • 异常请求来源 IP/设备/租户分布

19.2 推荐监控埋点

meterRegistry.counter("cache.product.hit").increment();
meterRegistry.counter("cache.product.empty.hit").increment();
meterRegistry.counter("cache.product.bloom.reject").increment();
meterRegistry.counter("cache.product.db.query").increment();
meterRegistry.counter("cache.product.db.empty").increment();
meterRegistry.timer("cache.product.query.latency").record(duration);

19.3 告警建议

以下条件建议触发告警:

  • Bloom 拦截量突增
  • 空对象命中率在短时间内明显抬升
  • 某接口 DB 空查询占比超过阈值
  • Redis 命中率骤降
  • 某类 key 的锁竞争异常升高

19.4 日志建议

不要把每次空查询都打印 error,否则攻击期间日志本身会把磁盘打爆。

建议:

  • 对空查询记录采样日志
  • 对异常来源记录聚合日志
  • 对阈值突破记录结构化告警日志

二十、压测设计:如何验证方案真的有效

上线前至少做三类压测。

20.1 正常流量压测

目标:

  • 验证正常业务下额外防护链路不会显著拉高延迟

关注:

  • P95/P99
  • CPU
  • Redis RT
  • Bloom 操作耗时

20.2 穿透流量压测

目标:

  • 模拟大量不存在 key 的访问

建议构造:

  • 连续不存在 ID
  • 随机不存在 ID
  • 同一不存在 ID 高频重复访问

要看:

  • Bloom 拦截率
  • 空对象命中率
  • 数据库空查询下降比例

20.3 混合故障压测

最有价值,也最容易被忽略。

建议模拟:

  • Redis RT 升高
  • Redis 节点短暂不可用
  • Bloom 过滤器未初始化
  • 数据库连接池阈值收紧

验证系统是否还能:

  • 限流
  • 降级
  • 快速失败
  • 保护核心业务

二十一、常见误区与踩坑清单

21.1 误区一:只要用了 Bloom 就万事大吉

错误原因:

  • Bloom 只是前置存在性判断
  • 它不能替代空对象缓存、回源保护和网关限流

21.2 误区二:Bloom 只在应用启动时全量加载一次

错误原因:

  • 数据是持续增长的
  • 过滤器会逐渐过期

必须有:

  • 初始化
  • 增量维护
  • 定期重建

21.3 误区三:空对象 TTL 设得很长

风险:

  • 新数据创建后仍被负缓存遮挡

21.4 误区四:删除数据后立刻删缓存,不做空对象兜底

风险:

  • 删除后的链接、搜索引擎、客户端重试都会直打 DB

21.5 误区五:Bloom 元素维度不完整

例如:

  • 多租户没加 tenantId
  • 多渠道没加 channel
  • 多版本没加 version

最终导致错误放行或错误拦截。

21.6 误区六:把所有空查询都当成攻击

不一定。

很多空查询来自:

  • 用户输入错误
  • 旧链接回访
  • 数据已删除
  • 下游调用延迟

所以治理要分层:既要防攻击,也要兼顾正常业务行为。

二十二、从单机到大规模系统的演进路线

阶段一:基础版

适合:

  • 单体应用
  • QPS 不高

方案:

  • 参数校验
  • Redis 缓存
  • 空对象缓存

阶段二:进阶版

适合:

  • 中等并发
  • 主键规模较大

方案:

  • 基础版能力
  • Bloom Filter
  • 本地缓存
  • 分布式锁

阶段三:生产强化版

适合:

  • 微服务
  • 高并发
  • 多实例部署

方案:

  • 网关限流/风控
  • Bloom 全量 + 增量 + 重建
  • 两级缓存
  • singleflight
  • 完整监控与告警

阶段四:平台化版

适合:

  • 多业务线共享缓存治理能力

方案:

  • 抽象统一缓存 SDK
  • 统一埋点
  • 统一 Bloom 管理服务
  • 统一规则平台

大型团队通常走到第四阶段,避免每个服务都重复造轮子。

二十三、生产级配置建议

以下参数没有绝对值,但可以作为初始参考。

23.1 TTL 建议

类型 建议值 说明
正常缓存 TTL 30min - 2h 结合业务更新频率
空对象 TTL 30s - 5min 尽量短,防负缓存过旧
本地缓存 TTL 30s - 2min 承接热点,弱一致
锁等待时间 50ms - 200ms 避免线程长时间阻塞
锁持有时间 1s - 5s 依据回源耗时设置

23.2 Bloom 建议

指标 建议
误判率 0.001 起步
预估容量 按未来 1.5 倍到 2 倍数据量规划
重建周期 每周或每月
维护方式 离线全量 + 在线增量

23.3 监控阈值建议

指标 建议告警阈值
DB 空查询占比 超过 10% 持续 5 分钟
Bloom 拦截量 较基线升高 3 倍以上
Redis 命中率 较日常下降 20% 以上
锁竞争失败率 超过 5%

这些值需要结合你自己的业务基线微调。

二十四、Kubernetes 与云原生落地建议

在云原生环境中,缓存穿透治理还要考虑部署层面的现实问题。

24.1 应用副本弹性扩缩容

副本数变化会带来:

  • 本地缓存命中率波动
  • 锁竞争模式变化
  • 热 key 分布变化

因此要避免把一致性逻辑依赖在本地缓存上。

24.2 Redis 高可用

建议:

  • Redis Cluster 或哨兵模式
  • 设置合理超时和重试
  • 严格限制重试次数,避免故障时放大流量

24.3 配置动态化

建议将以下配置动态下发:

  • 空对象 TTL
  • Bloom 开关
  • 回源限流阈值
  • 黑名单规则
  • 降级策略开关

这样在攻击期间无需重启服务即可调优。

24.4 运维作业

建议把以下能力平台化:

  • Bloom 初始化作业
  • Bloom 重建作业
  • 热 key 巡检
  • 空查询报表
  • 异常流量分析

二十五、一个完整的生产案例

下面给出一个更贴近真实场景的案例。

25.1 背景

某内容平台“文章详情接口”日均 QPS 8000,活动期间峰值 5 万。攻击者通过脚本扫描不存在文章 ID,数据库空查询一度占比超过 35%。

25.2 原系统问题

  • 只做了 Redis 正常缓存
  • 文章删除后直接删缓存
  • 无 Bloom
  • 无回源锁
  • 监控只有 Redis 命中率,没有空查询指标

25.3 改造方案

第一阶段:

  • 参数校验:文章 ID 必须 > 0
  • 删除文章时写短 TTL 空对象
  • 接口增加空查询埋点

第二阶段:

  • 引入 Bloom,存量 3000 万文章 ID 全量灌装
  • 写路径新增文章后异步增量写入
  • 读路径增加 Bloom 拦截

第三阶段:

  • 增加 Caffeine 本地缓存
  • key 级分布式锁
  • 网关按 IP/UA 限流

25.4 改造成果

  • 数据库空查询下降 92%
  • Redis 总命中率提升 11%
  • 高峰期接口 P99 从 380ms 降到 75ms
  • 攻击期间数据库 CPU 稳定在安全阈值内

25.5 关键经验

  • 先补监控,再补方案,否则无法验证收益
  • 删除场景一定要处理负缓存
  • Bloom 不是一次性工程,必须重建
  • 防护必须从网关到数据库形成闭环

二十六、文章级总结:如何构建一套真正可上线的缓存穿透防护体系

如果用一句话总结:

缓存穿透治理的核心,不是“把 null 缓存起来”,而是用分层拦截、负结果缓存、存在性过滤、并发控制和数据库保护,构建完整的读链路防线。

真正可靠的生产级方案,通常包含以下组合:

  • 参数合法性校验
  • 网关限流与风控
  • Redis 正常缓存
  • 空对象缓存
  • Bloom Filter
  • 本地热点缓存
  • singleflight 或分布式锁
  • DB 回源限流
  • 完整监控、告警和重建机制

如果你的系统只做了其中一项,仍然有较大概率在高并发或攻击场景下暴露脆弱性。

二十七、落地 Checklist

上线前可以逐项自查。

  • 是否对请求参数做了前置合法性校验
  • 是否缓存了空结果而不是直接返回 null
  • 是否为空对象设置了更短 TTL
  • 是否为 TTL 增加了随机抖动
  • 是否引入了 Bloom Filter 做前置存在性判断
  • 是否有 Bloom 的全量初始化和增量维护机制
  • 是否有 Bloom 的定期重建机制
  • 是否对缓存 miss 的并发回源做了 singleflight 或互斥保护
  • 是否有本地缓存承接热点
  • 是否有限流、熔断、降级保护数据库
  • 是否埋点统计了空查询、Bloom 拦截、锁竞争和 DB 空查占比
  • 是否为攻击流量准备了动态调参能力

如果以上大部分都已具备,那么你的系统才算真正拥有了生产级缓存穿透防护能力。

附录 A:简化版接口示例

@RestController
@RequestMapping("/api/products")
public class ProductController {

    private final ProductReadService productReadService;

    public ProductController(ProductReadService productReadService) {
        this.productReadService = productReadService;
    }

    @GetMapping("/{id}")
    public ResponseEntity<ProductDTO> getById(@PathVariable("id") Long id) {
        ProductDTO dto = productReadService.getProduct(id);
        if (dto == null) {
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok(dto);
    }
}

附录 B:最终结论

缓存穿透从来不是 Redis 的单点问题,而是高并发系统中“无效请求治理”的一部分。

它考验的是一整套工程能力:

  • 你能否在最前面识别无效请求
  • 能否在缓存层表达负结果
  • 能否在高并发下控制回源
  • 能否在异常时期保护数据库
  • 能否通过监控和重建维持方案长期有效

当你把这些能力组合起来,缓存穿透才真正从“线上隐患”变成“可控问题”。欢迎在云栈社区与更多开发者交流此类架构实践。




上一篇:SpringBoot 3.x与多引擎OCR车牌识别系统深度拆解:从单机到K8s的高并发演进
下一篇:XGBoost原理推导与代码实战:用梯度提升树预测房价
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-4-12 03:39 , Processed in 0.596483 second(s), 39 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表