缓存三大劫攻防战:穿透、击穿、雪崩的Java实战防御体系(一)

在高并发系统的稳定性战役中,缓存故障往往是压垮系统的"最后一根稻草"。某电商平台因缓存雪崩导致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并发线程高频调用接口:

  1. 这些手机号在Redis和MySQL中均不存在,缓存完全失效;
  2. 接口未做有效拦截,所有请求穿透至MySQL;
  3. 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请求。

根因解剖
  1. 商品ID为自增整数(从1000000开始),爬虫通过遍历ID批量抓取;
  2. 未上架商品的ID在DB中不存在,导致缓存穿透;
  3. 爬虫使用分布式节点,IP分散,传统限流难以拦截。
针对性方案:布隆过滤器+动态失效

核心优化

  1. 布隆过滤器仅载入"已上架商品ID"(过滤未上架商品);
  2. 商品上架时同步添加ID到过滤器,下架时通过"逻辑标记"而非删除处理(避免布隆过滤器删除缺陷)。

实战效果:MySQL凌晨查询量从5000QPS降至120QPS,问题彻底解决。

穿透防御总结

方案 适用场景 优点 缺点 实施成本
参数校验 key格式固定(如手机号) 无额外存储,性能高 无法拦截格式合法的无效key
缓存空值 无效key量可控 实现简单,无需预加载 缓存膨胀风险,需处理数据同步
布隆过滤器 有效key集合稳定 拦截率高,空间效率好 有误判率,不支持删除
限流降级 突发流量防护 兜底保障,不依赖业务规则 可能影响正常用户体验
相关推荐
崇山峻岭之间5 分钟前
Matlab学习记录35
开发语言·学习·matlab
济61711 分钟前
linux 系统移植(第六期)--Uboot移植(5)--bootcmd 和 bootargs 环境变量-- Ubuntu20.04
java·前端·javascript
温暖小土21 分钟前
深度解析 Spring Boot 自动配置:从原理到实践
java·springboot
Marktowin25 分钟前
Mybatis-Plus更新操作时的一个坑
java·后端
R-sz28 分钟前
如何将json行政区划导入数据库,中国行政区域数据(省市区县镇乡村五级联动)
java·数据库·json
比奇堡派星星40 分钟前
Linux OOM Killer
linux·开发语言·arm开发·驱动开发
定仙游45342 分钟前
Java StringBuilder 超详细讲解
java
haiyu柠檬44 分钟前
IDEA和VSCode中好用的插件推荐
java·vscode·intellij-idea
怜淇1 小时前
docker拉取openjdk8:jre失败
java·docker·容器
hqwest1 小时前
码上通QT实战11--监控页面03-绘制湿度盘和亮度盘
开发语言·qt·绘图·自定义组件·部件·qpainter·温度盘