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

发布于:2025-09-13 ⋅ 阅读:(12) ⋅ 点赞:(0)

在高并发系统的稳定性战役中,缓存故障往往是压垮系统的“最后一根稻草”。某电商平台因缓存雪崩导致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拦截器)

/**
 * 手机号注册校验拦截器:拦截格式无效、高频重复的请求
 */
@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封装)

@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分布式布隆过滤器)

@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配置)

@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集合稳定 拦截率高,空间效率好 有误判率,不支持删除
限流降级 突发流量防护 兜底保障,不依赖业务规则 可能影响正常用户体验