恶意的 id=-1
缓存的价值建立在「同一个 key 会被反复读」上。可如果有人拿一批根本不存在的商品 id——负数、随机数、超长字符串——狂刷接口呢?每个 key 在缓存里都查不到,每个请求都穿过缓存打到 DB,缓存形同虚设,数据库替它挨了全部子弹。这就是缓存穿透:查询的数据在缓存和数据库里都不存在。
ItemDetail d = redis.get("item:detail:-1"); // null,未命中
d = itemDao.loadDetail(-1); // null,DB 也白查一次
// 攻击者换着 id 刷:每个请求都完整穿过缓存层,DB 扛下全部流量注意它与「正常的未命中」不同:正常业务里新商品上架前的偶尔未命中无伤大雅,穿透是大量请求系统性地产出空结果——要么恶意,要么 bug。
解法一:空值缓存
最直接的思路:既然「不存在」这个事实也会被反复查询,那就把它也缓存起来——查库确认不存在后,写入一个短 TTL 的空值标记,后续同样的 id 在缓存层就直接返回。
ItemDetail d = itemDao.loadDetail(id);
if (d == null) {
redis.setex(key, 60, "NULL"); // 「不存在」也缓存 60 秒
return null;
}
redis.setex(key, 1800, toJson(d));简单有效,但有两个注意点:TTL 要短(商品真上架后,最多脏一分钟,可接受);攻击者若每次都换随机 id,空值缓存会反过来把缓存打满——所以空值缓存要配容量上限(比如只缓存空值 key 的哈希前缀),或者交给更彻底的方案。
解法二:布隆过滤器
在缓存前面再加一道闸:把全部合法商品 id 预先装进一个布隆过滤器——一个位数组加 k 个哈希函数的巧结构。它的承诺很有性格:说「不存在」就一定不存在,说「存在」可能误判。用误判的那一点点概率,换来了惊人的空间效率——上亿级 id 也就百 MB 内存。
// 启动时把全部合法商品 id 装进布隆过滤器
BloomFilter<Long> filter = BloomFilter.create(
Funnels.longFunnel(), 10_000_000, 0.01); // 预期 1000 万,误判率 1%
public ItemDetail getItem(Long id) {
if (!filter.mightContain(id)) {
return null; // 一定不存在:直接挡掉,不碰缓存不碰 DB
}
// ... 走正常缓存流程(误判的极少数会落到这,无害)
}两个工程细节:标准布隆过滤器不支持删除(下架商品只能等全量重建,或换 Counting Bloom / RedisBloom 模块);数据量大时重建耗时,要用定时任务在低峰期增量维护。
解法三与怎么选
第三道闸在更外面:入口校验。id 格式不对(雪花 id 有固定长度范围)、参数越界、同一来源请求频率异常——在网关和参数校验层就拦掉,成本最低,也最治本。它治的是已知规则,布隆过滤器治的是海量未知 id,两者不冲突。
| 解法 | 优点 | 代价 |
|---|---|---|
| 空值缓存 | 实现简单,立即生效 | 随机 id 会占内存,需配容量上限 |
| 布隆过滤器 | 内存极省,挡得彻底 | 有误判率,删除困难,需维护重建 |
| 入口校验与限流 | 治本,成本最低 | 只能挡已知规则的攻击 |
实战组合拳:入口校验打头阵,布隆过滤器守中军,空值缓存做补丁,限流系列讲过的网关限流再兜一层底。穿透挡住了「查不到的」,可「查得到的」也有危险时刻——爆款商品缓存过期的那一秒,就是下一篇的主角:击穿。
评论 (0)