在高并发系统的稳定性战役中,缓存故障往往是压垮系统的"最后一根稻草"。某电商平台因缓存雪崩导致DB连接池耗尽,大促期间瘫痪2小时,直接损失超800万元;某支付系统因缓存穿透引发MySQL主从延迟,造成5000笔交易对账异常;某社交APP因热点用户缓存击穿,导致明星账号主页连续30分钟无法访问,登上热搜引发舆情危机。
这些真实案例印证了一个残酷事实:缓存不是"银弹",而是需要精心设计防御体系的"战场前沿"。本文跳出"概念堆砌"的传统框架,采用"故障现场→根因解剖→方案落地→实战验证"的实战结构,通过6个跨行业案例,拆解12套可直接复用的Java防御方案,包含22段核心代码、7张可视化图表和5个避坑指南,形成5000字的"问题-方案-验证"闭环手册。
第一部分:缓存穿透------"不存在的key"引发的DB轰炸
缓存穿透的本质是"请求的key在缓存和DB中均不存在",导致缓存完全失效,所有请求直达DB,形成"DB轰炸"。这种攻击成本极低(仅需生成无效key),但破坏力极大(可能直接击垮数据库)。
案例1:社交APP注册接口的"恶意撞库"事件
故障现场
某社交APP上线"一键注册"功能,核心接口/api/v1/register/check
需校验手机号是否已注册,架构为"Redis+MySQL":
- 正常流程:查询Redis→未命中则查MySQL→将结果写入Redis(存在则存"1",不存在则不存)。
- 故障爆发:上线第3天晚8点,监控显示MySQL查询量从500QPS飙升至12000QPS,CPU使用率达99%,连接池耗尽,正常用户注册失败。日志显示大量"13800000000""13800000001"等连续未注册手机号请求。
根因解剖
通过流量分析工具发现,攻击方使用脚本生成1000万个格式合法的随机手机号(138开头+8位随机数),以100并发线程高频调用接口:
- 这些手机号在Redis和MySQL中均不存在,缓存完全失效;
- 接口未做有效拦截,所有请求穿透至MySQL;
- MySQL的
user
表虽对phone
字段建了索引,但12000QPS远超单表承载极限(约3000QPS),导致连接池耗尽。
四层防御体系落地
方案1:接口层参数校验(第一道防线)
核心逻辑 :通过业务规则拦截明显无效的请求,减少进入缓存层的恶意流量。
适配场景:key有明确格式约束(如手机号、身份证号、商品编码)。
实战代码(Spring Boot拦截器):
java
/**
* 手机号注册校验拦截器:拦截格式无效、高频重复的请求
*/
@Component
public class PhoneCheckInterceptor implements HandlerInterceptor {
// 手机号正则(严格校验:13/14/15/17/18/19开头,共11位)
private static final Pattern PHONE_PATTERN = Pattern.compile("^1[3-9]\\d{9}$");
// 本地缓存:记录1分钟内的请求次数(防高频重复)
private final LoadingCache<String, AtomicInteger> requestCounter = Caffeine.newBuilder()
.expireAfterWrite(1, TimeUnit.MINUTES)
.maximumSize(100000) // 支持10万级手机号
.build(key -> new AtomicInteger(0));
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String phone = request.getParameter("phone");
// 1. 空值校验
if (StringUtils.isEmpty(phone)) {
return writeError(response, "手机号不能为空", HttpStatus.BAD_REQUEST);
}
// 2. 格式校验(拦截非手机号格式的请求)
if (!PHONE_PATTERN.matcher(phone).matches()) {
return writeError(response, "手机号格式无效", HttpStatus.BAD_REQUEST);
}
// 3. 高频请求拦截(1分钟内同一手机号请求超5次则拦截)
try {
AtomicInteger counter = requestCounter.get(phone);
if (counter.incrementAndGet() > 5) {
return writeError(response, "请求过于频繁,请1分钟后再试", HttpStatus.TOO_MANY_REQUESTS);
}
} catch (Exception e) {
log.warn("请求计数缓存异常,phone={}", phone, e);
// 缓存异常不阻断正常请求,仅降级为不拦截
}
return true;
}
// 写入错误响应
private boolean writeError(HttpServletResponse response, String message, HttpStatus status) throws IOException {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(status.value());
response.getWriter().write(JSON.toJSONString(Result.fail(message)));
return false;
}
// 注册拦截器
@Configuration
public static class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new PhoneCheckInterceptor())
.addPathPatterns("/api/v1/register/check");
}
}
}
实战效果:拦截68%的恶意请求(格式错误+高频重复),MySQL查询量降至3800QPS,CPU使用率回落至60%。
方案2:缓存空值(快速拦截无效key)
核心逻辑 :对DB中不存在的key,在Redis中存储"业务空值标记"(如__EMPTY__
),并设置较短过期时间(5-10分钟),避免重复穿透。
关键设计:必须区分"业务空值"(如用户未下单)和"穿透空值"(如不存在的手机号),避免业务逻辑异常。
实战代码(RedisTemplate封装):
java
@Service
public class PhoneCacheService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private UserMapper userMapper;
// 穿透空值标记(与业务空值区分)
private static final String EMPTY_MARKER = "__EMPTY__";
// 空值过期时间(5分钟,平衡效率与一致性)
private static final long EMPTY_TTL = 300;
// 正常数据过期时间(1小时)
private static final long NORMAL_TTL = 3600;
// 缓存key前缀
private static final String CACHE_KEY_PREFIX = "user:phone:registered:";
/**
* 检查手机号是否已注册(带空值缓存)
*/
public boolean isRegistered(String phone) {
String cacheKey = CACHE_KEY_PREFIX + phone;
// 1. 查询缓存
String cacheVal = redisTemplate.opsForValue().get(cacheKey);
if (cacheVal != null) {
// 2. 命中空值标记:直接返回未注册
if (EMPTY_MARKER.equals(cacheVal)) {
log.info("空值缓存命中,phone={}", phone);
return false;
}
// 3. 命中正常值:返回注册状态
return Boolean.parseBoolean(cacheVal);
}
// 4. 缓存未命中:查询DB
boolean exists = userMapper.existsByPhone(phone);
// 5. 写入缓存(区分空值和正常值)
if (exists) {
redisTemplate.opsForValue().set(cacheKey, "true", NORMAL_TTL, TimeUnit.SECONDS);
} else {
redisTemplate.opsForValue().set(cacheKey, EMPTY_MARKER, EMPTY_TTL, TimeUnit.SECONDS);
}
return exists;
}
/**
* 手机号注册成功后,清理空值缓存(避免数据不一致)
*/
@Transactional(rollbackFor = Exception.class)
public void afterRegister(String phone) {
String cacheKey = CACHE_KEY_PREFIX + phone;
// 1. 删除空值缓存(若存在)
redisTemplate.delete(cacheKey);
// 2. 写入正常缓存
redisTemplate.opsForValue().set(cacheKey, "true", NORMAL_TTL, TimeUnit.SECONDS);
}
}
实战效果:MySQL查询量降至900QPS,缓存命中率从0%提升至85%,但空值过期后仍有少量穿透(约50QPS)。
方案3:布隆过滤器(拦截不存在的key)
核心逻辑 :在缓存层前部署布隆过滤器,提前载入DB中所有有效key(如已注册手机号)。请求到达时,先通过过滤器判断key是否"可能存在",不存在则直接拦截。
技术特性:
- 优势:空间效率极高(存储1000万手机号仅需12MB),查询时间O(1);
- 局限:存在误判率(可通过参数控制,通常设为0.01%),不支持删除操作。
实战实现(Redis分布式布隆过滤器):
java
@Configuration
public class BloomFilterConfig {
@Autowired
private RedissonClient redissonClient;
@Autowired
private UserMapper userMapper;
// 布隆过滤器名称
private static final String PHONE_FILTER_KEY = "bloom:user:phone";
// 预期数据量(1000万已注册手机号)
private static final long EXPECTED_SIZE = 10_000_000;
// 误判率(0.01%)
private static final double FALSE_RATE = 0.0001;
/**
* 初始化分布式布隆过滤器(项目启动时执行)
*/
@Bean
public RBloomFilter<String> phoneBloomFilter() {
RBloomFilter<String> filter = redissonClient.getBloomFilter(PHONE_FILTER_KEY);
// 仅首次创建时初始化
if (!filter.isExists()) {
filter.tryInit(EXPECTED_SIZE, FALSE_RATE);
// 分批次加载已注册手机号(避免OOM)
loadPhones(filter);
}
return filter;
}
// 分批次加载手机号到过滤器
private void loadPhones(RBloomFilter<String> filter) {
int pageSize = 5000;
int pageNum = 1;
while (true) {
PageHelper.startPage(pageNum, pageSize);
List<String> phones = userMapper.listAllPhones();
if (phones.isEmpty()) break;
filter.addAll(phones); // 批量添加(性能优于单条添加)
pageNum++;
}
log.info("布隆过滤器初始化完成,加载总量:{}", filter.count());
}
}
// 业务层整合布隆过滤器
@Service
public class RegisterService {
@Autowired
private RBloomFilter<String> phoneBloomFilter;
@Autowired
private PhoneCacheService cacheService;
public ResultDTO<Boolean> checkPhone(String phone) {
// 1. 布隆过滤器拦截:不存在则直接返回未注册
if (!phoneBloomFilter.contains(phone)) {
log.info("布隆过滤器拦截无效手机号:{}", phone);
return Result.success(false);
}
// 2. 过滤器命中:走缓存+DB流程
boolean registered = cacheService.isRegistered(phone);
return Result.success(registered);
}
// 新用户注册时,同步更新布隆过滤器
public void addPhoneToFilter(String phone) {
if (!phoneBloomFilter.contains(phone)) {
phoneBloomFilter.add(phone);
}
}
}
防御架构图:
[用户请求] → [接口层拦截器] → [Redis布隆过滤器] → [Redis缓存] → [MySQL]
↓ ↓ ↓
拦截无效格式 拦截不存在的key 拦截已存在的key
实战效果:布隆过滤器拦截99.6%的无效请求,MySQL查询量稳定在40QPS以内,CPU使用率降至15%,缓存命中率达99.3%。
方案4:限流降级(终极防护)
核心逻辑:通过限流组件(如Sentinel)对接口设置QPS阈值,即使前三层防御失效,也能将流量控制在DB可承载范围内。
实战代码(Sentinel配置):
java
@Configuration
public class SentinelConfig {
@PostConstruct
public void initRules() {
// 注册校验接口限流规则:QPS阈值3000(MySQL最大承载量)
FlowRule rule = new FlowRule();
rule.setResource("register:check:phone"); // 资源名
rule.setGrade(RuleConstant.FLOW_GRADE_QPS); // QPS限流
rule.setCount(3000); // 阈值
FlowRuleManager.loadRules(Collections.singletonList(rule));
}
}
// 接口层应用限流
@RestController
public class RegisterController {
@Autowired
private RegisterService registerService;
@GetMapping("/api/v1/register/check")
@SentinelResource(
value = "register:check:phone",
blockHandler = "checkBlocked" // 限流回调
)
public ResultDTO<Boolean> checkPhone(@RequestParam String phone) {
return registerService.checkPhone(phone);
}
// 限流回调:返回友好提示
public ResultDTO<Boolean> checkBlocked(String phone, BlockException e) {
log.warn("接口限流触发,phone={}", phone);
return Result.fail("系统繁忙,请稍后再试");
}
}
实战效果:即使前三层防御失效,也能将接口QPS控制在3000以内,确保MySQL不被压垮。
案例2:电商商品查询的"爬虫穿透"事件
故障现场
某电商平台商品详情接口/api/v1/item/{itemId}
,架构为"Redis+MySQL",支持按商品ID查询。运营发现每日凌晨2-4点,MySQL负载异常升高(QPS 5000+),日志显示大量"item:1000001""item:1000002"等连续不存在的商品ID请求。
根因解剖
- 商品ID为自增整数(从1000000开始),爬虫通过遍历ID批量抓取;
- 未上架商品的ID在DB中不存在,导致缓存穿透;
- 爬虫使用分布式节点,IP分散,传统限流难以拦截。
针对性方案:布隆过滤器+动态失效
核心优化:
- 布隆过滤器仅载入"已上架商品ID"(过滤未上架商品);
- 商品上架时同步添加ID到过滤器,下架时通过"逻辑标记"而非删除处理(避免布隆过滤器删除缺陷)。
实战效果:MySQL凌晨查询量从5000QPS降至120QPS,问题彻底解决。
穿透防御总结
方案 | 适用场景 | 优点 | 缺点 | 实施成本 |
---|---|---|---|---|
参数校验 | key格式固定(如手机号) | 无额外存储,性能高 | 无法拦截格式合法的无效key | 低 |
缓存空值 | 无效key量可控 | 实现简单,无需预加载 | 缓存膨胀风险,需处理数据同步 | 低 |
布隆过滤器 | 有效key集合稳定 | 拦截率高,空间效率好 | 有误判率,不支持删除 | 中 |
限流降级 | 突发流量防护 | 兜底保障,不依赖业务规则 | 可能影响正常用户体验 | 中 |