Redis常见问题“诊疗手册”:从苍穹外卖实战出发
Redis常见问题“诊疗手册”:从苍穹外卖实战出发
一、缓存穿透:“小偷试探所有门锁”
1. 问题本质与场景
- 定义:大量“无效ID请求”(如查询不存在的员工ID=9999)穿透Redis,直接冲击数据库,导致CPU飙升。
- 苍穹场景:黑客用随机无效ID请求
/employee/{id},或前端传参错误,Redis无对应缓存,所有请求打向MySQL。
2. 问题流程(文字流程图)
客户端请求(员工ID=9999)
→ 【判断】Redis缓存:未命中(无此ID缓存)
→ 数据库查询:返回空结果
→ 【关键漏洞】未写入任何缓存(空值也没存)
→ 客户端收到“员工不存在”
→ 1000个重复请求循环上述步骤 → 数据库压力骤增
3. 解决方案:布隆过滤器+空值缓存
核心逻辑
用布隆过滤器先拦无效ID,再用空值缓存拦截“漏网”的误判请求,双重防护。
文字流程图(解决方案)
客户端请求(员工ID=9999)
→ 【第一层防护】布隆过滤器判断:
├─ 不存在 → 直接返回“员工不存在”(拦截99.9%无效请求)
└─ 可能存在 → 【第二层防护】Redis缓存判断:
├─ 命中(空值/有效数据)→ 返回结果
└─ 未命中 → 数据库查询:
├─ 空结果 → 写入“空值缓存”(5分钟过期,拦截剩余0.1%误判)→ 返回“不存在”
└─ 有结果 → 写入“有效缓存”(30分钟+随机偏移)→ 脱敏后返回
4. 核心代码(苍穹外卖实战版)
步骤1:布隆过滤器初始化(项目启动执行)
package com.sky.config;
import com.sky.mapper.EmployeeMapper;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
@Component
public class BloomFilterInit implements CommandLineRunner {
@Autowired
private RedissonClient redissonClient;
@Autowired
private EmployeeMapper employeeMapper;
private static final String EMPLOYEE_BLOOM_KEY = "bloom:employee:id";
@Override
public void run(String... args) {
// 1. 初始化布隆过滤器:预计存5万ID,误判率0.001
RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter(EMPLOYEE_BLOOM_KEY);
bloomFilter.tryInit(50000, 0.001);
// 2. 加载所有有效员工ID(状态=启用)
bloomFilter.addAll(employeeMapper.selectAllValidIds());
System.out.println("布隆过滤器初始化完成,加载有效ID数量:" + employeeMapper.selectAllValidIds().size());
}
}
步骤2:业务层实现双重防护
package com.sky.service.impl;
import com.sky.constant.CacheConstant;
import com.sky.constant.MessageConstant;
import com.sky.entity.Employee;
import com.sky.exception.AccountNotFoundException;
import com.sky.mapper.EmployeeMapper;
import com.sky.utils.RedisCache;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Service
public class EmployeeServiceImpl {
@Autowired
private RedisCache redisCache;
@Autowired
private EmployeeMapper employeeMapper;
@Autowired
private RedissonClient redissonClient;
public Employee searchById(Long id) {
// 1. 布隆过滤器拦无效ID
RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter("bloom:employee:id");
if (!bloomFilter.contains(id)) {
throw new AccountNotFoundException(MessageConstant.ACCOUNT_NOT_FOUND);
}
// 2. 查Redis缓存
String cacheKey = CacheConstant.EMPLOYEE_KEY + id;
Employee employee = (Employee) redisCache.get(cacheKey);
if (employee != null) {
employee.setPassword("********"); // 脱敏
return employee;
}
// 3. 查数据库
employee = employeeMapper.selectById(id);
if (employee == null) {
// 4. 缓存空值(5分钟过期,防重复穿透)
Employee nullEmployee = new Employee();
nullEmployee.setId(-1L); // 标记空值
redisCache.set(cacheKey, nullEmployee, 5, TimeUnit.MINUTES);
throw new AccountNotFoundException(MessageConstant.ACCOUNT_NOT_FOUND);
}
// 5. 缓存有效数据(30分钟+随机偏移,防雪崩)
int randomOffset = (int) (Math.random() * 30);
redisCache.set(cacheKey, employee, 30 + randomOffset, TimeUnit.MINUTES);
employee.setPassword("********");
return employee;
}
}
5. 总结
- 适用场景:数据量大(万级+)、无效ID查询多的场景(员工、菜品、分类)。
- 关键注意:布隆过滤器误判率需平衡(误判率越低→内存占用越高);空值缓存必须短期过期,避免“新增有效数据后缓存未更新”。
二、缓存击穿:“双十一大促热门商品”
1. 问题本质与场景
- 定义:热点Key(如“热销菜品分类ID=1”)缓存过期时,大量并发请求同时穿透Redis,集中查数据库,导致数据库崩溃。
- 苍穹场景:午餐高峰期,“快餐分类”缓存过期,1000个商家同时请求
/category/{id},Redis未命中,所有请求打向MySQL。
2. 问题流程(文字流程图)
客户端1(商家)请求分类ID=1 → Redis未命中(缓存过期)→ 查数据库
客户端2(商家)请求分类ID=1 → Redis未命中(缓存过期)→ 查数据库
...
客户端1000(商家)请求分类ID=1 → Redis未命中(缓存过期)→ 查数据库
→ 数据库同时处理1000个请求 → CPU 100% → 超时错误
3. 解决方案:分布式锁+缓存重建
核心逻辑
用分布式锁控制“仅一个线程查数据库并重建缓存”,其他线程等待缓存重建后直接读Redis,避免并发查库。
文字流程图(解决方案)
客户端1请求分类ID=1 → Redis未命中 → 尝试获取分布式锁(lock:category:1)
→ 锁获取成功 → 【双重检查】Redis仍未命中 → 查数据库 → 写入Redis缓存 → 释放锁
→ 客户端1返回结果
客户端2请求分类ID=1 → Redis未命中 → 尝试获取锁(已被占用)→ 等待
→ 客户端1释放锁 → 客户端2获取锁 → 【双重检查】Redis已命中 → 释放锁 → 返回结果
客户端3~1000:后续请求直接命中Redis → 返回结果
4. 核心代码(苍穹外卖实战版)
步骤1:分布式锁工具类(基于Redisson)
package com.sky.utils;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
@Component
public class DistributedLockUtil {
@Autowired
private RedissonClient redissonClient;
// 函数式接口:接收业务逻辑,返回泛型结果
@FunctionalInterface
public interface BusinessLogic<T> {
T execute();
}
// 加锁并执行业务:等待10秒,锁10秒后自动释放(防死锁)
public <T> T executeWithLock(String lockKey, BusinessLogic<T> business) {
RLock lock = redissonClient.getLock(lockKey);
try {
boolean isLocked = lock.tryLock(10, 10, TimeUnit.SECONDS);
if (!isLocked) {
throw new RuntimeException("系统繁忙,请稍后重试");
}
return business.execute(); // 执行传入的业务逻辑
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("请求被中断");
} finally {
// 确保锁释放(仅释放当前线程持有的锁)
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
步骤2:业务层用锁重建缓存
package com.sky.service.impl;
import com.sky.constant.CacheConstant;
import com.sky.dto.CategoryPageQueryDTO;
import com.sky.entity.Category;
import com.sky.mapper.CategoryMapper;
import com.sky.result.PageResult;
import com.sky.utils.DistributedLockUtil;
import com.sky.utils.RedisCache;
import com.github.pagehelper.Page;
import com.github.pagehelper.PageHelper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.DigestUtils;
import java.util.concurrent.TimeUnit;
@Service
public class CategoryServiceImpl {
@Autowired
private RedisCache redisCache;
@Autowired
private CategoryMapper categoryMapper;
@Autowired
private DistributedLockUtil distributedLockUtil;
public PageResult pageQuery(CategoryPageQueryDTO dto) {
// 1. 构建唯一缓存Key(含分页参数,避免冲突)
String queryStr = dto.getPage() + "_" + dto.getPageSize() + "_" + (dto.getType() == null ? "" : dto.getType());
String cacheKey = CacheConstant.CATEGORY_LIST_KEY + DigestUtils.md5DigestAsHex(queryStr.getBytes());
// 2. 先查缓存
PageResult pageResult = (PageResult) redisCache.get(cacheKey);
if (pageResult != null) {
return pageResult;
}
// 3. 缓存未命中:加锁防击穿
String lockKey = CacheConstant.CATEGORY_LOCK_KEY + DigestUtils.md5DigestAsHex(queryStr.getBytes());
return distributedLockUtil.executeWithLock(lockKey, () -> {
// 4. 双重检查:防止等待期间缓存已重建
PageResult cachedResult = (PageResult) redisCache.get(cacheKey);
if (cachedResult != null) {
return cachedResult;
}
// 5. 查数据库(分页)
PageHelper.startPage(dto.getPage(), dto.getPageSize());
Page<Category> page = categoryMapper.pageQuery(dto);
pageResult = new PageResult(page.getTotal(), page.getResult());
// 6. 重建缓存(30分钟+随机偏移,防雪崩)
int randomOffset = (int) (Math.random() * 30);
redisCache.set(cacheKey, pageResult, 30 + randomOffset, TimeUnit.MINUTES);
return pageResult;
});
}
}
5. 总结
- 适用场景:存在热点Key(热销分类、热门菜品)、缓存过期后并发请求多的场景。
- 关键注意:锁Key必须与缓存Key一一对应(细粒度锁),避免“一把大锁影响所有请求”;锁需设超时时间,防止服务崩溃导致死锁。
三、缓存雪崩:“春运抢票服务器崩溃”
1. 问题本质
大量缓存Key在同一时间集中过期(如所有员工缓存都设“1小时后过期”),导致所有请求瞬间打向数据库。
2. 问题流程(文字流程图)
09:00:员工/分类/菜品缓存同时生效(均设1小时过期)
10:00:所有缓存同时过期 → Redis无任何缓存数据
→ 客户端1~1000请求 → 全部穿透Redis → 查数据库
→ 数据库CPU 100% → 拒绝服务
3. 解决方案:错峰过期+熔断降级
文字流程图(解决方案)
1. 缓存写入时添加随机偏移:
员工缓存:30~60分钟过期
分类缓存:35~65分钟过期
菜品缓存:40~70分钟过期
→ 缓存过期时间分散,无集中过期点
2. 数据库压力过大时熔断:
请求 → Redis未命中 → 查数据库 → 【判断】数据库压力是否超限
├─ 未超限 → 查库并重建缓存 → 返回结果
└─ 已超限 → 触发熔断 → 返回兜底数据(如默认分类列表)→ 保护数据库
核心代码
步骤1:错峰过期(写入缓存时实现)
// 所有缓存写入逻辑都加随机偏移,避免集中过期
int randomOffset = (int) (Math.random() * 30); // 0~30分钟随机
redisCache.set(cacheKey, data, 30 + randomOffset, TimeUnit.MINUTES); // 总过期30~60分钟
步骤2:熔断降级(基于Sentinel)
package com.sky.service.impl;
import com.alibaba.csp.sentinel.annotation.SentinelResource;
import com.alibaba.csp.sentinel.slots.block.BlockException;
import com.sky.dto.CategoryPageQueryDTO;
import com.sky.result.PageResult;
import org.springframework.stereotype.Service;
import java.util.Collections;
@Service
public class CategoryServiceImpl {
// @SentinelResource:数据库压力超限时,触发fallback方法
@SentinelResource(value = "categoryPageQuery", fallback = "pageQueryFallback")
public PageResult pageQuery(CategoryPageQueryDTO dto) {
// 正常查询逻辑(查缓存→查数据库)
String cacheKey = "category:page:" + dto.getPage() + ":" + dto.getPageSize();
PageResult result = (PageResult) redisCache.get(cacheKey);
if (result == null) {
PageHelper.startPage(dto.getPage(), dto.getPageSize());
Page<Category> page = categoryMapper.pageQuery(dto);
result = new PageResult(page.getTotal(), page.getResult());
redisCache.set(cacheKey, result, 30 + (int)(Math.random()*30), TimeUnit.MINUTES);
}
return result;
}
// 兜底方法:返回空列表,避免用户看到错误
public PageResult pageQueryFallback(CategoryPageQueryDTO dto, BlockException e) {
System.out.println("数据库压力过大,触发熔断:" + e.getMessage());
return new PageResult(0L, Collections.emptyList());
}
}
4. 总结
- 适用场景:缓存Key数量多、过期时间易集中的场景(如全量数据缓存)。
- 关键注意:兜底数据需符合业务逻辑(如返回空列表而非错误),避免影响用户体验。
四、全局总结:Redis问题诊疗决策树(文字版)
遇到Redis问题 → 先判断问题类型:
1. 无效ID穿透Redis → 用“布隆过滤器+空值缓存”(数据量大)或“仅空值缓存”(数据量小)
2. 热点Key过期后并发查库 → 用“分布式锁+缓存重建”
3. 大量Key集中过期 → 用“错峰过期+熔断降级”
4. 分布式系统竞争资源(如库存扣减)→ 用“Redisson分布式锁”
5. 单个Key访问量过高(如网红菜品)→ 用“本地缓存(Guava)+Redis集群”

浙公网安备 33010602011771号