缓存穿透与布隆过滤器

面试官问:"什么是缓存穿透?"

小陈说:"就是缓存没命中,去查数据库了。"

面试官追问:"如果大量恶意请求查询不存在的数据,缓存和数据库都查不到,会怎样?"

小张说:"...数据库压力很大?"

面试官继续追问:"怎么解决?布隆过滤器是什么原理?"

小陈彻底卡住了。

缓存穿透是 Redis 缓存架构中最常见的问题之一。如果不防护,恶意用户可以用大量不存在的数据请求击垮数据库。这道题能说清楚穿透原因、布隆过滤器原理的候选人,对 Redis 的工程实践有较深理解。

一、缓存穿透的本质 🔴

1.1 穿透的定义

正常请求:
请求 → Redis(有数据) → 直接返回  ✅

正常穿透:
请求 → Redis(无数据) → 查数据库 → 回填缓存 → 返回  ⚠️(轻微)

恶意穿透:
请求 → Redis(无数据) → 查数据库(也不存在) → 不回填 → 每次都查数据库  ❌

1.2 穿透的常见原因

-- 原因 1:业务攻击
-- 恶意用户用大量不存在的 ID 请求
-- user_id = 99999999, 99999998, 99999997 ...

-- 原因 2:数据误删
-- 数据库删了数据,但缓存没删
-- 查询时缓存没有,去查数据库也没有

-- 原因 3:爬虫/误操作
-- 爬虫爬取大量不存在的 URL
-- 用户输入错误的 ID

1.3 ❌ 错误示范

候选人原话:"缓存穿透就是缓存过期了,需要回源。"

问题诊断:混淆了缓存穿透和缓存击穿。穿透是查不到数据,击穿是大量请求同时查同一个过期 key。

候选人原话 2:"布隆过滤器可以 100% 防止穿透。"

问题诊断:布隆过滤器有误判率,不能 100% 防止。存在"布隆说过存在,但实际不存在"的误判情况。

【面试官心理】 这道题我能从"穿透 vs 击穿 vs 雪崩"的对比切入。能说清楚三种问题的区别和各自解决方案的候选人,说明他对缓存问题有系统理解。

二、布隆过滤器原理 🔴

2.1 布隆过滤器的结构

# 布隆过滤器 = N 个哈希函数 + M 位的位数组
# N = 哈希函数个数
# M = 位数组大小(如 1 亿位 ≈ 12.5MB)

# 添加元素:
# 1. 用 N 个哈希函数计算 N 个哈希值
# 2. 将 N 个哈希值映射到位数组的 N 个位置
# 3. 将这 N 个位置设为 1

# 查询元素:
# 1. 用 N 个哈希函数计算 N 个哈希值
# 2. 检查这 N 个位置是否都是 1
# 3. 如果都是 1:可能存在(误判)
#    如果有任何一个不是 0:一定不存在(精确)

2.2 图解

位数组(10位):
位置:  0  1  2  3  4  5  6  7  8  9
初始:  0  0  0  0  0  0  0  0  0  0

添加 "user:1"(3个哈希函数):
Hash1 → 位置 1
Hash2 → 位置 3
Hash3 → 位置 5
位数组: 0  1  0  1  0  1  0  0  0  0

添加 "user:2":
Hash1 → 位置 3(已占用)
Hash2 → 位置 7
Hash3 → 位置 9
位数组: 0  1  0  1  0  1  0  1  0  1

查询 "user:3":
Hash1 → 位置 5 → 是 1 ✓
Hash2 → 位置 9 → 是 1 ✓
Hash3 → 位置 2 → 是 0 ✗
结论:一定不存在

查询 "user:4":
Hash1 → 位置 0 → 是 0 ✗
结论:一定不存在

查询 "user:5"(不存在但哈希冲突):
Hash1 → 位置 1 → 是 1 ✓
Hash2 → 位置 3 → 是 1 ✓
Hash3 → 位置 7 → 是 1 ✓
结论:可能存在(误判!)

2.3 布隆过滤器的参数

-- 三个关键参数:
-- 1. n:预计插入的元素数量
-- 2. m:位数组大小
-- 3. k:哈希函数个数

-- 最优公式:
-- m = - (n * ln(p)) / (ln(2)^2)
-- k = (m / n) * ln(2)

-- 其中 p = 期望的误判率

-- 示例:n=1亿,p=1%
-- m = - (100000000 * ln(0.01)) / (ln(2)^2) ≈ 958506291 bits ≈ 114MB
-- k = 7

三、布隆过滤器在 Redis 中的应用 🟡

3.1 RedisBloom 模块

# 安装 RedisBloom 模块
docker run -d --name redis \
  -p 6379:6379 \
  redislabs/rebloom:latest
import redis
r = redis.Redis()
rb = redis BloomFilter(r, 'user_ids', error_rate=0.01, capacity=10000000)

# 添加元素
rb.add('user:1000')

# 查询元素
exists = rb.exists('user:1000')  # True/False(可能误判)

# 防御穿透
user_id = request.params['user_id']
if not bloom.exists(f'user:{user_id}'):
    return '用户不存在'  # 直接返回,不查数据库
user = redis.get(f'user:{user_id}')
if not user:
    user = db.get(f'SELECT * FROM users WHERE id={user_id}')
    if user:
        redis.set(f'user:{user_id}', user)
        bloom.add(f'user:{user_id}')
return user

3.2 布隆过滤器的误判率

-- 误判率的影响:
-- - p=1%:每 100 个不存在的 key,误判 1 个去查数据库
-- - p=0.1%:每 1000 个不存在的 key,误判 1 个去查数据库

-- 布隆过滤器不能删除元素(删除会误判其他元素)
-- 如果需要删除,方案:
-- 1. 定期重建布隆过滤器
-- 2. 使用 Countinuous Bloom Filter / Cuckoo Filter

3.3 布隆过滤器的局限性

-- 优点:
-- - 空间效率高(1 亿数据 ≈ 12MB)
-- - 查询效率高(O(k),k 通常 7~10)
-- - 支持分布式(RedisBloom 模块)

-- 缺点:
-- - 有误判率(false positive)
-- - 不能删除元素
-- - 不支持获取元素
-- - 不支持计数

四、其他穿透解决方案 🟡

4.1 缓存空值

# 简单方案:缓存空值
user = redis.get(f'user:{user_id}')
if user is None:
    user = db.get(f'SELECT * FROM users WHERE id={user_id}')
    if user:
        redis.setex(f'user:{user_id}', 3600, user)
    else:
        # 缓存空值,过期时间短一些
        redis.setex(f'user:{user_id}', 60, 'NULL')

# 问题:
# - 如果大量不同 ID 查询,都要缓存空值
# - 浪费内存
# - 适合:查询不存在的 key 种类不多

4.2 参数校验

# 在网关层校验参数合法性
user_id = request.params['user_id']
if not user_id.isdigit():
    return '非法参数'
if int(user_id) > 10000000:  # ID 最大值
    return '用户不存在'

4.3 布隆 + 空值组合

# 最优方案:布隆过滤器 + 空值缓存
def get_user(user_id):
    # Step 1: 布隆过滤器判断
    if not bloom.exists(f'user:{user_id}'):
        return None  # 一定不存在,不查数据库

    # Step 2: 查缓存
    user = redis.get(f'user:{user_id}')
    if user:
        return user

    # Step 3: 查数据库
    user = db.get(f'SELECT * FROM users WHERE id={user_id}')
    if user:
        redis.setex(f'user:{user_id}', 3600, user)
    else:
        redis.setex(f'user:{user_id}', 60, 'NULL')
    return user

【面试官心理】 缓存穿透是 Redis 面试中的高频题。能说清楚布隆过滤器的原理、误判率和适用场景的候选人,说明他对 Redis 的工程实践有深入理解。


级别考察重点期望回答
P5问题认知缓存穿透的定义和危害
P6解决方案布隆过滤器原理、空值缓存
P7深度应用参数校验、误判率调优、布隆过滤器局限性