缓存穿透的解决方法

缓存穿透的核心是 “拦截对不存在数据的无效查询”,常见解决方式有 4 种,各自的实现逻辑、优劣和适用场景如下:

1. 布隆过滤器(Bloom Filter)

实现逻辑

  • 用一个位数组记录 “所有合法存在的 key”(如用户 ID、商品 ID),查询前先通过布隆过滤器判断 key 是否 “可能存在”:
    • 若判断 “不存在”,直接返回,不查缓存和数据库;
    • 若判断 “可能存在”,再走正常的 “缓存→数据库” 流程。

优点

  • 空间效率极高:存储 100 万条数据仅需约 100KB(误判率 1% 时),适合海量数据场景;
  • 查询速度快:微秒级响应,几乎不影响接口性能;
  • 抗攻击性强:即使恶意请求大量无效 key,也能在过滤器层被拦截。

缺点

  • 存在误判率(可通过参数调优降低,但无法消除):可能将不存在的 key 误判为 “可能存在”,导致少量无效请求穿透到数据库;
  • 不支持删除:若数据被删除(如用户注销),无法从布隆过滤器中移除,需通过过期重建解决;
  • 需预热:需提前将数据库中已存在的 key 全量导入过滤器,初始化成本较高。

适用场景

  • 有明确 “合法 key 集合” 且数据更新不频繁的场景(如商品 ID、用户 ID 查询);
  • 海量数据场景(百万级以上),对内存占用敏感。

2. 空值缓存(Null Cache)

实现逻辑

  • 当数据库查询结果为空(key 不存在)时,仍将这个 “空结果” 存入缓存,设置较短的过期时间(如 5-60 秒):
    • 后续相同的无效请求会命中 “空缓存”,直接返回,不再次查询数据库。

优点

  • 实现简单:无需额外组件,仅需在缓存逻辑中增加 “空结果处理”;
  • 无额外误判:完全基于实际查询结果,不会误拦截有效请求;
  • 动态适应:新产生的无效 key 会自动被缓存,无需预热。

缺点

  • 内存浪费:恶意攻击时,大量不同的无效 key 会生成大量空缓存,占用 Redis 内存;
  • 短期不一致:若后续该 key 被创建(如新增用户),空缓存未过期前会导致 “新数据查不到”(需设置较短过期时间缓解)。

适用场景

  • 无效请求量小、key 分布分散的场景(如正常用户偶尔查询错误 ID);
  • 快速临时防护(如突发少量无效请求时)。

3. 接口层参数校验

实现逻辑

  • 在接口入口(如 controller 层)对请求参数进行合法性校验,直接拦截明显无效的 key:
    • 例如:用户 ID 必须是正整数且在合理范围(1-10 亿),商品分类 ID 必须在预设枚举值内等。

优点

  • 零成本:仅需代码逻辑判断,无需额外存储或组件;
  • 无副作用:完全基于业务规则,不会影响正常请求;
  • 前置拦截:在请求链路最上游拦截,不占用缓存和数据库资源。

缺点

  • 覆盖范围有限:只能拦截 “格式或范围明显不合理” 的 key,无法识别 “格式合法但实际不存在” 的 key(如 ID=12345 是合法格式,但对应用户不存在);
  • 依赖业务规则:需随业务变化更新校验逻辑(如用户 ID 范围扩大时需同步调整)。

适用场景

  • 作为基础防护手段,与其他方法配合使用;
  • 参数有明确格式 / 范围约束的场景(如订单号、手机号等)。

4. 白名单机制(反向校验)

实现逻辑

  • 维护一个 “绝对存在的 key 列表”(白名单),仅允许白名单内的 key 通过查询:
    • 例如:将所有已存在的用户 ID 存入 Redis 集合(Set),查询时先判断 ID 是否在集合中,不在则直接拦截。

优点

  • 零误判:白名单内的 key 一定存在,拦截逻辑绝对准确;
  • 实现简单:基于 Redis 的SISMEMBER命令即可实现。

缺点

  • 内存占用高:白名单需存储完整的 key(如字符串类型的用户 ID),100 万条数据约占用 50MB(远高于布隆过滤器的 100KB);
  • 维护成本高:数据新增 / 删除时需实时同步白名单(如用户注册时添加到 Set,注销时移除),否则会出现 “漏判”。

适用场景

  • key 数量少(万级以内)且更新频繁的场景;
  • 对误判零容忍的核心业务(如支付相关 ID 查询)。

总结对比表

方式 核心逻辑 优点 缺点 适用场景
布隆过滤器 位数组标记合法 key 空间效率高、抗攻击 有误判、不支持删除 海量数据、ID 查询
空值缓存 缓存不存在的 key 结果 实现简单、无预热成本 内存浪费、短期不一致 无效请求少、临时防护
接口校验 拦截格式 / 范围无效参数 零成本、前置拦截 覆盖有限、依赖业务规则 基础防护,配合其他方法使用
白名单机制 仅允许存在的 key 查询 零误判、实现简单 内存占用高、维护成本高 数据量少、对误判零容忍

最佳实践

实际项目中通常组合使用多种方式

  • 基础防护:接口层参数校验(拦截明显无效请求);
  • 核心防护:布隆过滤器(拦截大部分 “格式合法但不存在” 的请求);
  • 补充防护:空值缓存(拦截布隆过滤器误判的少量请求)。

这种多层防护既能覆盖大部分场景,又能平衡性能、内存和一致性。

要理解 QPS,首先要明确它是衡量系统处理请求能力的核心性能指标,尤其在你之前讨论的 “缓存 - 数据库” 架构(如虚拟电厂交易平台)中,QPS 直接决定了系统能否应对高并发请求、避免页面卡顿或服务崩溃。

一、QPS 的定义:每秒查询率

QPS 是 Queries Per Second 的缩写,中文译为 “每秒查询率”,指的是系统在 1 秒钟内能够处理的 “查询类请求” 的数量

这里的 “查询请求” 是核心 —— 比如:

  • 虚拟电厂平台中,用户查询某台设备的实时发电量(GET /device/123/power);
  • 缓存层(如 Redis)处理 “判断某个设备 ID 是否存在” 的请求;
  • 数据库(如 MySQL)处理 “查询某时段交易记录” 的 SQL 请求。

简单说:QPS 越高,说明系统单位时间内处理查询的能力越强。

二、QPS 的核心作用:评估系统性能与瓶颈

在你之前关注的 “缓存穿透”“数据库压力” 等场景中,QPS 是判断系统是否健康的关键标尺,主要用于 3 件事:

  1. 评估系统承载能力
    不同组件的 QPS 上限差异极大,这也是 “缓存 - 数据库” 分层架构的核心原因:
    • 缓存(如 Redis):单机 QPS 通常能达到 10 万 - 100 万级(内存操作,速度极快);
    • 数据库(如 MySQL):单机 QPS 通常只有 1 万 - 5 万级(磁盘 IO 操作,速度慢);
    • 应用服务器(如 Spring Boot):单机 QPS 通常 1 万 - 10 万级(取决于业务逻辑复杂度)。
      这就是为什么之前说 “缓存要拦住大部分请求”—— 如果大量请求穿透到数据库,很容易超过其 QPS 上限,导致查询排队、耗时变长(对应你说的 “页面卡顿”)。
  2. 规划系统扩容与压测
    业务上线前,会通过 “压测” 模拟高并发场景,比如虚拟电厂平台在 “用电高峰时段” 可能有 10 万用户同时查询设备状态,此时需要确保:
    • 缓存层 QPS 能扛住 10 万(不够就加 Redis 集群);
    • 穿透到数据库的 QPS 控制在 1 万以内(通过布隆过滤器 + 空值缓存)。
      若压测发现数据库 QPS 超过 5 万(达到其上限),就必须扩容(如分库分表)或优化缓存策略。
  3. 定位性能瓶颈
    比如虚拟电厂平台突然出现页面卡顿,查看 QPS 指标:
    • 若缓存 QPS 很低(如 1000),但数据库 QPS 很高(如 8000,接近上限)—— 说明缓存没起作用,大概率是缓存穿透(对应你之前的问题);
    • 若缓存 QPS 很高(如 5 万),数据库 QPS 正常(如 5000)—— 卡顿可能是缓存本身过载,需要加缓存节点。

三、容易混淆的概念:QPS vs TPS

很多人会把 QPS 和 TPS(Transactions Per Second,每秒事务数)搞混,两者的核心区别在于 “请求是否包含完整事务”,在你的业务场景中也需要区分:

  • QPS(查询率):针对 “只读查询”,比如 “查设备状态”“查交易记录”—— 这类请求只需要系统返回数据,不需要修改数据,操作单一;
  • TPS(事务率):针对 “包含读写的完整事务”,比如 “虚拟电厂的电力交易下单”—— 这个操作需要先查用户余额(读)、扣减余额(写)、生成订单(写)、更新库存(写),多个步骤组成一个 “事务”,必须全部成功或全部失败。

简单说:所有 TPS 都包含 QPS(事务中的查询步骤),但 QPS 不一定是 TPS。比如你的平台中,“查询设备状态” 是 QPS,“完成一笔电力交易” 是 TPS。

四、关键补充:峰值 QPS 与平均 QPS

实际业务中,QPS 不是固定的,会随时间波动,需要关注两个核心值:

  • 平均 QPS:一天 / 一小时内的平均请求数,用于日常资源规划(如虚拟电厂平台白天平均 QPS 可能是 1 万);
  • 峰值 QPS:某一时刻的最大请求数,用于应对突发流量(如用电高峰 / 交易截止前,峰值 QPS 可能达到 10 万)。
    系统设计必须按 “峰值 QPS” 来配置资源,否则高峰期会因 QPS 超过上限导致服务不可用。

总结

QPS 是衡量 “系统每秒能处理多少查询请求” 的核心指标,直接关系到你关心的 “页面是否卡顿”“数据库是否过载”。在虚拟电厂这类高并发场景中,优化 QPS 的核心逻辑就是:让缓存扛住大部分查询请求(高 QPS),只让少量必要请求穿透到数据库(低 QPS),这也是之前讨论 “布隆过滤器 + 空值缓存” 的底层逻辑之一。“缓存预热” 和 “延迟过期” 是缓存架构中两种提升稳定性的常用策略,前者解决 “新数据 / 热点数据首次查询穿透数据库” 的问题,后者避免 “大量缓存同时过期导致的缓存雪崩”。以下结合实际场景(如虚拟电厂平台)说明具体实现方式:

一、缓存预热:提前加载热点数据到缓存

核心目标:在用户请求到来前,主动将高频访问的数据(如热门设备实时数据、活跃用户信息)加载到 Redis,避免首次查询穿透到数据库。

1. 实现方式:按场景选择加载策略

(1)系统启动时全量预热(适合核心基础数据)
  • 适用场景:数据量不大但访问频繁的基础数据(如虚拟电厂的 “区域列表”“设备类型字典”)。
  • 实现步骤
    1. 系统启动后,通过初始化函数触发预热;
    2. 从数据库批量查询所有基础数据;
    3. 批量写入 Redis(设置合理过期时间)。
  • 代码示例(GoFrame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 程序启动时执行缓存预热
func init() {
ctx := context.Background()
// 1. 查询所有区域基础数据
regions, err := dao.Region.Ctx(ctx).All()
if err != nil {
g.Log().Error(ctx, "区域数据预热失败", err)
return
}
// 2. 批量写入Redis
pipe := g.Redis().Pipeline()
for _, region := range regions {
key := fmt.Sprintf("region:info:%d", region.Id)
data, _ := gjson.Encode(region)
pipe.SetEx(ctx, key, 24*time.Hour, data) // 过期时间24小时
}
_, err = pipe.Exec(ctx)
if err != nil {
g.Log().Error(ctx, "区域数据写入缓存失败", err)
} else {
g.Log().Info(ctx, "区域数据缓存预热完成,共", len(regions), "条")
}
}
(2)定时任务增量预热(适合动态更新的热点数据)
  • 适用场景:数据频繁更新但有明显热点(如虚拟电厂中 “近 1 小时发电量 Top10 的设备数据”)。
  • 实现步骤
    1. 用定时任务(如每 10 分钟)统计近期访问量最高的前 N 条数据(通过访问日志或数据库统计);
    2. 从数据库查询这些热点数据;
    3. 写入 / 更新 Redis 缓存(覆盖旧值)。
  • 代码示例(GoFrame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 定时预热热点设备数据(每10分钟执行一次)
func initHotDeviceCache() {
gtimer.Add(context.Background(), 10*time.Minute, func(ctx context.Context) {
// 1. 统计近1小时访问量Top50的设备ID
hotDeviceIDs, err := getTopNDeviceIDs(ctx, 50, 1*time.Hour)
if err != nil || len(hotDeviceIDs) == 0 {
return
}
// 2. 批量查询这些设备的最新数据
devices, err := dao.Device.Ctx(ctx).Where("id IN (?)", hotDeviceIDs).All()
if err != nil {
g.Log().Error(ctx, "热点设备数据查询失败", err)
return
}
// 3. 更新缓存
pipe := g.Redis().Pipeline()
for _, device := range devices {
key := fmt.Sprintf("device:info:%d", device.Id)
data, _ := gjson.Encode(device)
pipe.SetEx(ctx, key, 1*time.Hour, data) // 过期时间1小时(短于定时周期,确保数据新鲜)
}
_, _ = pipe.Exec(ctx)
g.Log().Info(ctx, "热点设备缓存预热完成,共", len(devices), "条")
})
}
(3)基于用户行为预测预热(适合突发流量场景)
  • 适用场景:可预测的流量高峰(如虚拟电厂平台 “每天 9 点用户集中查看昨日收益”)。
  • 实现步骤:
    1. 分析历史流量规律,确定高峰前的 “预热窗口期”(如每天 8:30);
    2. 提前查询即将被高频访问的数据(如 “所有用户的昨日收益”);
    3. 批量写入 Redis,过期时间覆盖高峰时段(如 8:30-10:00)。

2. 优点与注意事项

  • 优点:消除首次查询的数据库穿透,降低高峰时段数据库压力;
  • 注意事项:
    • 避免预热数据量过大导致 Redis 内存溢出(只预热 “真正的热点数据”);
    • 若数据更新频繁,需控制预热周期(如 10 分钟一次),避免缓存与数据库长期不一致。

二、延迟过期:给缓存设置随机过期时间偏移

核心目标:避免大量缓存 key 在同一时间点过期(否则会导致 “缓存雪崩”—— 所有请求同时穿透到数据库),通过随机偏移分散过期时间。

1. 实现方式:基础过期时间 + 随机偏移

  • 核心逻辑:设置缓存时,在 “基础过期时间”(如 2 小时)上叠加一个 “随机偏移量”(如 0-30 分钟),让不同 key 的过期时间错开。
  • 代码示例(GoFrame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 存储设备数据到缓存时,添加随机过期偏移
func setDeviceCache(ctx context.Context, device *entity.Device) error {
key := fmt.Sprintf("device:info:%d", device.Id)
data, err := gjson.Encode(device)
if err != nil {
return err
}
// 基础过期时间2小时,随机偏移0-30分钟
baseExpire := 2 * time.Hour
randomOffset := time.Duration(grand.Intn(30)) * time.Minute // 0-30分钟随机数
totalExpire := baseExpire + randomOffset
_, err = g.Redis().SetEx(ctx, key, totalExpire, data)
return err
}

2. 优点与注意事项

  • 优点:分散缓存过期时间,避免 “瞬间全量失效” 导致的数据库压力峰值;
  • 注意事项:
    • 随机偏移量不宜过大(如不超过基础过期时间的 20%),否则可能导致缓存数据长期不更新;
    • 配合 “主动更新” 机制(如数据修改时同步更新缓存),避免缓存过期前数据已变更。

总结

  • 缓存预热:通过 “主动加载” 热点数据到缓存,解决 “首次查询穿透” 问题,实现方式包括启动时全量加载、定时增量更新、行为预测预热;
  • 延迟过期:通过 “随机过期偏移” 分散缓存失效时间,避免 “缓存雪崩”,核心是在基础过期时间上叠加随机值。

两者结合可大幅提升缓存架构的稳定性,尤其适合虚拟电厂这类高并发、数据实时性要求高的场景。

你的方案具有很强的可行性,本质是通过 “真实访问行为统计” 动态识别热点数据,避免了初期主观判断热点的偏差,尤其适合内测阶段(缺乏历史数据)的场景。以下从可行性分析、优化细节和落地步骤三个方面展开:

一、方案可行性:核心逻辑合理,适合内测阶段

你的思路本质是 “基于实际访问量的热点数据发现”,核心优势在于:

  1. 真实性:通过用户 / 系统的实际调用次数(自增指标)判断热点,比 “拍脑袋” 预设热点(如 “猜测某类设备是热点”)更准确;
  2. 低侵入性:内测阶段只需添加 “访问计数” 逻辑,不影响核心业务,符合 “先收集数据再优化” 的迭代思路;
  3. 易落地:技术实现简单(用 Redis 的INCR做自增计数,定期扫描计数取 Top N),无需复杂的统计模型。

二、需优化的细节:避免常见问题

1. 访问指标的存储与过期策略

  • 问题:若缓存数据过期,但访问指标未同步清理,会导致统计 “已失效数据的访问量”,干扰热点判断。
  • 解决:
    • 为每个缓存 key 的访问指标(如key:access_count)设置与缓存 key 相同的过期时间(或稍长 10 分钟,确保能统计到最后一次访问);
    • 例如:缓存 key device:info:123 过期时间 2 小时,则其访问指标 device:info:123:count 也设置 2 小时 10 分钟过期。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 访问缓存时,同步自增计数(GoFrame示例)
func getDeviceFromCache(ctx context.Context, deviceID int) (*entity.Device, error) {
key := fmt.Sprintf("device:info:%d", deviceID)
// 1. 尝试从缓存获取数据
data, err := g.Redis().Get(ctx, key)
if err != nil {
return nil, err
}
// 2. 若命中缓存,自增访问计数
if !data.IsEmpty() {
countKey := fmt.Sprintf("%s:count", key)
// 设置计数key的过期时间(比缓存key长10分钟)
_, _ = g.Redis().IncrEx(ctx, countKey, 2*time.Hour+10*time.Minute)
}
// 3. 后续反序列化逻辑...
}

2. 热点数据的统计周期与时效性

  • 问题:若统计周期过长(如 1 天),可能包含 “过时热点”(如某设备仅在上午被高频访问,下午无人问津);周期过短(如 10 分钟),可能因数据量不足导致误判。
  • 解决:
    • 内测阶段可设置 “滑动窗口统计”:每小时统计一次过去 1 小时的访问量(而非固定周期),兼顾时效性和数据量;
    • 例如:每天凌晨 2 点统计 “过去 24 小时的 Top 100 访问量”,同时保留 “过去 1 小时 Top 20”,后续预热时优先取 “重叠数据”(既长期热门又短期高频)。

3. 冷数据的指标清理

  • 问题:大量低访问量的缓存 key(如访问次数 < 5)的计数会占用 Redis 内存,且无实际意义。

  • 解决:

    • 统计热点数据后,批量删除访问量低于阈值(如 5 次)的计数 key;
    • 可在 “读取指标生成热门数据组” 的脚本中添加清理逻辑:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // 生成热门数据组后,清理低访问量计数
    func cleanLowCountKeys(ctx context.Context, threshold int) {
    // 模糊匹配所有计数key(需确保Redis key命名规范统一)
    countKeys, _ := g.Redis().Keys(ctx, "*:count").Result()
    for _, key := range countKeys {
    count, _ := g.Redis().Get(ctx, key).Int()
    if count < threshold {
    g.Redis().Del(ctx, key)
    }
    }
    }

三、落地步骤:分阶段实现,风险可控

  1. 内测版本(第一阶段)
    • 仅添加 “访问计数” 逻辑(用 Redis INCR),不做预热;
    • 部署定时任务(如每小时),扫描所有计数 key,记录访问量 Top N(如 Top 200),存储到 “候选热门数据组”(如 Redis 的hot:data:candidate集合)。
  2. 数据验证阶段
    • 观察 1-2 周的候选热门数据组,分析是否符合业务直觉(如虚拟电厂中,“居民光伏设备” 是否确实比 “工业储能设备” 访问更频繁);
    • 调整阈值(如访问量 Top 100 还是 Top 200)、统计周期(1 小时还是 24 小时),确保热点数据的准确性。
  3. 下一版本(第二阶段)
    • 基于验证后的热门数据组,实现缓存预热(如系统启动时加载 Top 100,定时任务每小时更新 Top 20);
    • 上线后对比预热前后的数据库穿透率(如从 10% 降至 2%),验证效果。

结论

你的方案完全可行,且符合 “从实际数据出发” 的迭代思路,尤其适合内测阶段缺乏历史数据的场景。核心是通过 “统一的计数 key 命名 + 同步过期策略 + 定期清理冷数据” 解决细节问题,后续基于统计结果落地预热时,能更精准地命中真实热点,避免无效预热。