深度解析:用户注册场景下的“缓存穿透”防御战
视频
引子:缓存的双刃剑
在如今微服务遍地的互联网项目中,使用缓存(比如 Redis)是提高效率最常用的手段。尤其是在 读多写少 的场景下,缓存能极大地降低数据库压力。
但是,引入缓存同样也带来了一系列需要解决的问题。今天我们要深入探讨的,就是其中最棘手的问题之一:缓存穿透。
什么是缓存穿透?
定义
缓存穿透是指:查询的数据在 缓存和数据库中都不存在,导致每次查询都会"穿透"缓存,直接查询数据库。
危害有多大?
我们都知道,缓存存在的意义就是为了缓解数据库的压力。那么问题来了:
如果短时间内发生大量缓存穿透请求,所有请求都直接打到数据库,极有可能造成数据库宕机。
产生缓存穿透的原因
常见解决方案分析
既然了解了缓存穿透的危害,我们来看看业界常见的解决方案,并分析它们各自的优缺点。
方案一:缓存空对象
思路:当查询的数据在缓存和数据库中都不存在时,就缓存一个空结果(比如 null),并设置一个过期时间。
public User getUser(String phone) {
// 1. 查缓存
User user = redisCache.get(phone);
// 2. 缓存有值(包括空值标记)
if (user != null) {
return user == NULL_MARKER ? null : user;
}
// 3. 查数据库
user = userMapper.selectByPhone(phone);
// 4. 数据库也没有,缓存空值
if (user == null) {
redisCache.set(phone, NULL_MARKER, 30, TimeUnit.SECONDS);
return null;
}
// 5. 数据库有,缓存真实数据
redisCache.set(phone, user);
return user;
}
流程示意:
🤔 但问题来了:让我们用用户注册业务来分析
结论:缓存空对象适合 空值能被复用 的场景。但对于用户注册这种每次查询的 key 都不同的业务,不太适合。
方案二:分布式锁
思路:使用分布式锁,每次只允许一个请求去查询数据库,其他请求等待。
public User getUser(String phone) {
// 1. 查缓存
User user = redisCache.get(phone);
if (user != null) {
return user;
}
// 2. 获取分布式锁
String lockKey = "lock:user:" + phone;
try {
if (redisLock.tryLock(lockKey)) {
// 3. 双重检查
user = redisCache.get(phone);
if (user != null) {
return user;
}
// 4. 查数据库
user = userMapper.selectByPhone(phone);
// 5. 写入缓存
redisCache.set(phone, user);
return user;
} else {
// 等待后重试
Thread.sleep(100);
return getUser(phone);
}
} finally {
redisLock.unlock(lockKey);
}
}
流程示意:
🤔 问题分析:
结论:分布式锁适合 并发量不高 的场景。对于大麦网这种高并发项目,不太适合。
方案三:布隆过滤器
思路:在查询缓存之前,先用布隆过滤器判断数据是否存在。如果布隆过滤器说"不存在",就直接返回,不再查询缓存和数据库。
布隆过滤器原理:
布隆过滤器的特点:
| 特点 | 说明 |
|---|---|
| 空间效率极高 | 存储空间是常数 O(k),远小于存储原始数据 |
| 查询效率极高 | 时间复杂度 O(k),k 是哈希函数个数 |
| 支持海量数据 | 非常适合大数据场景 |
| 存在误判 | 说"存在"可能是误判,说"不存在"一定准确 |
| 无法删除 | 因为多个元素可能映射到同一位置 |
🤔 误判问题分析:
用户注册业务的最终解决方案
通过上文分析,我们发现每个方案都有各自的问题:
| 方案 | 问题 |
|---|---|
| 缓存空对象 | 空值无法复用,不适合每次 key 都不同的场景 |
| 分布式锁 | 串行执行,严重影响高并发性能 |
| 布隆过滤器 | 存在误判,需要配合其他手段 |
那么,对于大麦网这种高并发系统,该如何解决呢?
答案是:从业务角度出发,多层防御!
业务分析:用户注册场景
在真实的业务场景中,大家在其他平台注册或登录时,应该遇到过这样的操作:
图形验证码的作用:
- 缓解突发流量:增加了用户操作步骤,自然减缓请求速度
- 防止机器人攻击:机器很难自动完成图形验证
- 防刷:恶意用户无法批量发起请求
最终方案:四层防御体系
代码流程示意
public void register(UserRegisterDto dto) {
// ================== 第一层:验证码校验 ==================
if (needCaptcha()) {
boolean captchaValid = captchaService.verify(dto.getCaptchaToken());
if (!captchaValid) {
throw new BusinessException("验证码校验失败");
}
}
// ================== 第二层:请求数限制 ==================
// 根据数据库性能配置,比如每秒最多 1000 个注册请求
Long currentCount = redisCache.incr("register:count:" + currentSecond());
if (currentCount > REGISTER_LIMIT_PER_SECOND) {
throw new BusinessException("系统繁忙,请稍后重试");
}
// ================== 第三层:布隆过滤器 ==================
boolean mayExist = bloomFilter.mightContain(dto.getPhone());
if (!mayExist) {
// 布隆过滤器说"不存在",一定不存在,可以直接注册
doRegister(dto);
// 注册成功后,将手机号加入布隆过滤器
bloomFilter.add(dto.getPhone());
return;
}
// ================== 第四层:数据库确认 ==================
// 布隆过滤器说"存在",可能是误判,需要查数据库确认
User existUser = userMapper.selectByPhone(dto.getPhone());
if (existUser != null) {
throw new BusinessException("该手机号已注册");
}
// 数据库确认不存在(布隆误判),可以注册
doRegister(dto);
bloomFilter.add(dto.getPhone());
}
各层作用对比
| 防御层 | 作用 | 特点 |
|---|---|---|
| 图形验证码 | 拦截机器人、缓解流量 | 从源头减少恶意请求 |
| 请求数限制 | 保护数据库 | 兜底方案,可根据 DB 性能配置 |
| 布隆过滤器 | 快速过滤不存在的数据 | O(1) 时间复杂度,极高效率 |
| 数据库索引 | 处理误判情况 | 有索引时查询效率也很高 |
效果分析
核心总结
| 问题 | 分析 | 解决方案 |
|---|---|---|
| 缓存空对象不适用 | 每个用户手机号不同,空值无法复用 | 改用布隆过滤器 |
| 分布式锁影响性能 | 串行执行,不适合高并发 | 改用无锁方案 |
| 布隆过滤器有误判 | 说"存在"可能是误判 | 误判时查数据库确认 |
| 恶意攻击防不住 | 纯技术手段有局限 | 图形验证码从业务层拦截 |
| 突发流量冲击 | 可能压垮数据库 | 请求数限制作为兜底 |
💡 设计思想:解决缓存穿透问题,不能只依赖单一的技术手段。正所谓技术都是围绕业务来服务的,一定要从业务角度出发,采用多层防御策略。图形验证码 + 请求数限制 + 布隆过滤器 + 数据库索引,层层过滤,才能在保证用户体验的同时,有效保护系统。
企业级项目导航:⬅️ 01-布隆过滤器顶层设计深度讲解 | 02-深度解析:用户注册场景下的“缓存穿透”防御战 | ➡️ 03-(缓存穿透)用户注册业务深度解析
💬 评论