缓存穿透的解决方法
缓存穿透的核心是 “拦截对不存在数据的无效查询”,常见解决方式有 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 件事:
- 评估系统承载能力
不同组件的 QPS 上限差异极大,这也是 “缓存 - 数据库” 分层架构的核心原因:- 缓存(如 Redis):单机 QPS 通常能达到 10 万 - 100 万级(内存操作,速度极快);
- 数据库(如 MySQL):单机 QPS 通常只有 1 万 - 5 万级(磁盘 IO 操作,速度慢);
- 应用服务器(如 Spring Boot):单机 QPS 通常 1 万 - 10 万级(取决于业务逻辑复杂度)。
这就是为什么之前说 “缓存要拦住大部分请求”—— 如果大量请求穿透到数据库,很容易超过其 QPS 上限,导致查询排队、耗时变长(对应你说的 “页面卡顿”)。
- 规划系统扩容与压测
业务上线前,会通过 “压测” 模拟高并发场景,比如虚拟电厂平台在 “用电高峰时段” 可能有 10 万用户同时查询设备状态,此时需要确保:- 缓存层 QPS 能扛住 10 万(不够就加 Redis 集群);
- 穿透到数据库的 QPS 控制在 1 万以内(通过布隆过滤器 + 空值缓存)。
若压测发现数据库 QPS 超过 5 万(达到其上限),就必须扩容(如分库分表)或优化缓存策略。
- 定位性能瓶颈
比如虚拟电厂平台突然出现页面卡顿,查看 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)系统启动时全量预热(适合核心基础数据)
- 适用场景:数据量不大但访问频繁的基础数据(如虚拟电厂的 “区域列表”“设备类型字典”)。
- 实现步骤:
- 系统启动后,通过初始化函数触发预热;
- 从数据库批量查询所有基础数据;
- 批量写入 Redis(设置合理过期时间)。
- 代码示例(GoFrame):
1 | // 程序启动时执行缓存预热 |
¶(2)定时任务增量预热(适合动态更新的热点数据)
- 适用场景:数据频繁更新但有明显热点(如虚拟电厂中 “近 1 小时发电量 Top10 的设备数据”)。
- 实现步骤:
- 用定时任务(如每 10 分钟)统计近期访问量最高的前 N 条数据(通过访问日志或数据库统计);
- 从数据库查询这些热点数据;
- 写入 / 更新 Redis 缓存(覆盖旧值)。
- 代码示例(GoFrame):
1 | // 定时预热热点设备数据(每10分钟执行一次) |
¶(3)基于用户行为预测预热(适合突发流量场景)
- 适用场景:可预测的流量高峰(如虚拟电厂平台 “每天 9 点用户集中查看昨日收益”)。
- 实现步骤:
- 分析历史流量规律,确定高峰前的 “预热窗口期”(如每天 8:30);
- 提前查询即将被高频访问的数据(如 “所有用户的昨日收益”);
- 批量写入 Redis,过期时间覆盖高峰时段(如 8:30-10:00)。
¶2. 优点与注意事项
- 优点:消除首次查询的数据库穿透,降低高峰时段数据库压力;
- 注意事项:
- 避免预热数据量过大导致 Redis 内存溢出(只预热 “真正的热点数据”);
- 若数据更新频繁,需控制预热周期(如 10 分钟一次),避免缓存与数据库长期不一致。
¶二、延迟过期:给缓存设置随机过期时间偏移
核心目标:避免大量缓存 key 在同一时间点过期(否则会导致 “缓存雪崩”—— 所有请求同时穿透到数据库),通过随机偏移分散过期时间。
¶1. 实现方式:基础过期时间 + 随机偏移
- 核心逻辑:设置缓存时,在 “基础过期时间”(如 2 小时)上叠加一个 “随机偏移量”(如 0-30 分钟),让不同 key 的过期时间错开。
- 代码示例(GoFrame):
1 | // 存储设备数据到缓存时,添加随机过期偏移 |
¶2. 优点与注意事项
- 优点:分散缓存过期时间,避免 “瞬间全量失效” 导致的数据库压力峰值;
- 注意事项:
- 随机偏移量不宜过大(如不超过基础过期时间的 20%),否则可能导致缓存数据长期不更新;
- 配合 “主动更新” 机制(如数据修改时同步更新缓存),避免缓存过期前数据已变更。
¶总结
- 缓存预热:通过 “主动加载” 热点数据到缓存,解决 “首次查询穿透” 问题,实现方式包括启动时全量加载、定时增量更新、行为预测预热;
- 延迟过期:通过 “随机过期偏移” 分散缓存失效时间,避免 “缓存雪崩”,核心是在基础过期时间上叠加随机值。
两者结合可大幅提升缓存架构的稳定性,尤其适合虚拟电厂这类高并发、数据实时性要求高的场景。
你的方案具有很强的可行性,本质是通过 “真实访问行为统计” 动态识别热点数据,避免了初期主观判断热点的偏差,尤其适合内测阶段(缺乏历史数据)的场景。以下从可行性分析、优化细节和落地步骤三个方面展开:
¶一、方案可行性:核心逻辑合理,适合内测阶段
你的思路本质是 “基于实际访问量的热点数据发现”,核心优势在于:
- 真实性:通过用户 / 系统的实际调用次数(自增指标)判断热点,比 “拍脑袋” 预设热点(如 “猜测某类设备是热点”)更准确;
- 低侵入性:内测阶段只需添加 “访问计数” 逻辑,不影响核心业务,符合 “先收集数据再优化” 的迭代思路;
- 易落地:技术实现简单(用 Redis 的
INCR做自增计数,定期扫描计数取 Top N),无需复杂的统计模型。
¶二、需优化的细节:避免常见问题
¶1. 访问指标的存储与过期策略
- 问题:若缓存数据过期,但访问指标未同步清理,会导致统计 “已失效数据的访问量”,干扰热点判断。
- 解决:
- 为每个缓存 key 的访问指标(如
key:access_count)设置与缓存 key 相同的过期时间(或稍长 10 分钟,确保能统计到最后一次访问); - 例如:缓存 key
device:info:123过期时间 2 小时,则其访问指标device:info:123:count也设置 2 小时 10 分钟过期。
- 为每个缓存 key 的访问指标(如
1 | // 访问缓存时,同步自增计数(GoFrame示例) |
¶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)
}
}
}
¶三、落地步骤:分阶段实现,风险可控
- 内测版本(第一阶段):
- 仅添加 “访问计数” 逻辑(用 Redis
INCR),不做预热; - 部署定时任务(如每小时),扫描所有计数 key,记录访问量 Top N(如 Top 200),存储到 “候选热门数据组”(如 Redis 的
hot:data:candidate集合)。
- 仅添加 “访问计数” 逻辑(用 Redis
- 数据验证阶段:
- 观察 1-2 周的候选热门数据组,分析是否符合业务直觉(如虚拟电厂中,“居民光伏设备” 是否确实比 “工业储能设备” 访问更频繁);
- 调整阈值(如访问量 Top 100 还是 Top 200)、统计周期(1 小时还是 24 小时),确保热点数据的准确性。
- 下一版本(第二阶段):
- 基于验证后的热门数据组,实现缓存预热(如系统启动时加载 Top 100,定时任务每小时更新 Top 20);
- 上线后对比预热前后的数据库穿透率(如从 10% 降至 2%),验证效果。
¶结论
你的方案完全可行,且符合 “从实际数据出发” 的迭代思路,尤其适合内测阶段缺乏历史数据的场景。核心是通过 “统一的计数 key 命名 + 同步过期策略 + 定期清理冷数据” 解决细节问题,后续基于统计结果落地预热时,能更精准地命中真实热点,避免无效预热。