Redis项目中常见问题
1、面试经常问的问题
1.缓存穿透
查询一个根本不存在的数据, 缓存层和存储层都不会命中。 另外,出于容错考虑, 从存储层查不到的数据也不写入缓存层。
1.1 可能原因:
- 自身业务代码或者数据出现问题
- 一些恶意攻击、 爬虫等造成大量空命中
1.2 解决方案:
1.2.1 限流
在网关层对用户进行校验来判断是不是来自恶意用户的请求,比如对请求参数进行校验和检查一段时间内请求同一个服务的次数。
常用的限流方式包括:nginx网关层限流 ;Google 提供的 RateLimiter 开源包;Sentinel流量治理组件;Hystrix限流;自定义限流等。
nginx
有两个专门的限流模块:HttpLimitzone 和 HttpLimitReqest,HttpLimitzone 用来限制一个客户端的并发连接数,HttpLimitReqest 通过漏桶算法来限制用户的连接频率。
表示:同一 ip 每秒最多1个请求(rate=1r/s),超过2个最大突发请求(burst=2 nodelay)将丢弃,页面返回503。
自定义限流
用一个线程安全的 ConcurrentLinkedQueue 预先存放一批ID。通过定时任务刷新ConcurrentLinkedQueue 中ID,比如:设定 100 毫秒刷新一次,1 秒钟最多获得 2000个ID,那每 100 毫秒最多有 200 个ID。
从定时生成服务获得批量 ID 数,这其实起到一个限流的作用,因为在从 ConcurrentLinkedQueue 获得 ID 的时候,如果没有获取到,会直接返回中断用户的请求处理,返回一个处理失败。
1.2.2.布隆过滤器
某个值存在时,这个值可能不存在;当它说不存在时,那就肯定不存在
适用于数据命中不高、 数据相对固定、 实时性低(通常是数据集较大) 的应用场景。代码维护较为复杂, 但是缓存空间占用很少。
使用谷歌guava布隆过滤器(hutool的也可以),初始化时加载所有数据
注册拦截器
请求到达时判断是否在过滤器中
1.2.3.缓存空对象
String get(String key) {
// 从缓存中获取数据
String cacheValue = cache.get(key);
// 缓存为空
if (StringUtils.isBlank(cacheValue)) {
// 从存储中获取
String storageValue = storage.get(key);
cache.set(key, storageValue);
// 如果存储数据为空, 需要设置一个过期时间(300秒)
if (storageValue == null) {
cache.expire(key, 60 * 5);
}
return storageValue;
} else {
// 缓存非空
return cacheValue;
}
}
2.缓存击穿
由于热点缓存失效后可能导致大量请求同时达到数据库,造成数据库瞬间压力过大甚至挂掉。
2.1 解决方案:
2.1.1.将缓存过期时间设置为一个时间段内的不同时间
2.1.2.热点key的过期时间设置为永不过期 & 本地双缓存(Caffeine)
Caffeine 基于 Google 的 Guava Cache,提供一个性能卓越的本地缓存(local cache) 实现, 也是 SpringBoot 内置的本地缓存实现,有资料表明 Caffeine性能是 Guava Cache 的 6 倍。
双缓存机制中,主缓存是最后一次写入后经过固定时间过期,备缓存是设置最后一次访问后经过固定时间过期。即备缓存中内容不管是读或写,过期时间都会后延;而主缓存中数据在被读取后,过期时间不会后延。
在本地缓存的异步刷新机制上,主缓存只有无效才会被重新写入,备缓存无论是否无效都会重新写入,可以保证备缓存中的数据不至于真的永不过期而太旧,还可以使备缓存的过期时间不管用户是否访问首页都可以不断后延。
@Bean(name = "mainCache")
public Cache<String, HomeContentResult> mainCache() {
int rnd = ThreadLocalRandom.current().nextInt(10);
return Caffeine.newBuilder()
// 设置最后一次写入经过固定时间过期
.expireAfterWrite(30 + rnd, TimeUnit.MINUTES)
// 初始的缓存空间大小
.initialCapacity(20)
// 缓存的最大条数
.maximumSize(100)
.build();
}
@Bean(name = "bakCache")
public Cache<String, HomeContentResult> bakCache() {
int rnd = ThreadLocalRandom.current().nextInt(10);
return Caffeine.newBuilder()
// 设置最后一次访问经过固定时间过期
.expireAfterAccess(41 + rnd, TimeUnit.MINUTES)
// 初始的缓存空间大小
.initialCapacity(20)
// 缓存的最大条数
.maximumSize(100)
.build();
}
先从本地缓存获取,没有再从redis获取
/*先从本地缓存中获取推荐内容*/
Result result = mainCache.getIfPresent(key) ;
if(result == null){
result = bakCache.getIfPresent(key);
}
/*本地缓存中没有,从redis获取*/
if(result == null){
result = getFromRedis();
if(null != result) {
mainCache.put(key,result);
bakCache.put(key,result);
}
}
2.1.3.利用互斥锁保证同一时刻只有一个客户端可以查询底层数据库的数据,一旦查询到就保存到redis
导入redisson
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.6.5</version>
</dependency>
伪代码
String lockKey = "lock:product_"+pid;
List<Entry> list = stringRedisTemplate.opsForList().range(lockKey ,0,-1)
if(CollectionUtils.isEmpty(list)){
//获取锁对象
RLock redissonLock = redisson.getLock(lockKey);
//加分布式锁
redissonLock.lock();
try {
list = mapper.selectListByKey(lockKey );
stringRedisTemplate.opsForList().rightPushAll(lockKey ,list);
} finally {
//解锁
redissonLock.unlock();
}
log.info("信息存入缓存,键{}" ,lockKey );
}else{
log.info("信息已在缓存,键{}" ,lockKey );
}
return list;
3.缓存雪崩
缓存击穿是一个热点key失效,雪崩是多个热点key同时失效,流量会像奔逃的野牛一样, 打向后端存储层。
3.1 解决方案:
3.1.1 在可接受的时间范围内随机设置key的过期时间,分散key的过期时间
参考缓存击穿,本地双缓存
3.1.2 key的过期时间设置为永不过期,根据实际业务情况而定
参考缓存击穿,本地双缓存
2、双写一致性
通过数据库的 binlog 异步淘汰 key,利用工具(canal)将 binlog日志采集后通过 ACK 机制确认处理删除缓存。这种模式也称为 Cache Aside Pattern。
Canal 是阿里开源的一个项目,官方主页:https://github.com/alibaba/canal
通过模拟 MySOL 主从复制的交互协议,把自己伪装成一个 MySOL 的从节点,向 MySOL 主节点发送 dump 请求。MySOL 收到请求后,会向 Canal 推送 Binlog,Canal 解析 Binlog 字节流之后,将其转换为便于读取的结构化数据,供下游程序订阅使用。
具体配置可以参考canal官方文档
项目中增加定时任务拉取canal解析后的数据
@Async
@Scheduled(initialDelayString="${canal.test.initialDelay:5000}",fixedDelayString = "${canal.test.fixedDelay:5000}")
@Override
public void processData() {
try {
if(!connector.checkValid()){
log.warn("与Canal服务器的连接失效!!!重连,下个周期再检查数据变更");
this.connect();
}else{
Message message = connector.getWithoutAck(batchSize);
long batchId = message.getId();
int size = message.getEntries().size();
if (batchId == -1 || size == 0) {
log.info("本次[{}]没有检测到数据更新。",batchId);
}else{
log.info("本次[{}]数据共有[{}]次更新需要处理",batchId,size);
/*一个表在一次周期内可能会被修改多次,而对Redis缓存的处理只需要处理一次即可*/
Set<String> factKeys = new HashSet<>();
for(CanalEntry.Entry entry : message.getEntries()){
if (entry.getEntryType() == CanalEntry.EntryType.TRANSACTIONBEGIN
|| entry.getEntryType() == CanalEntry.EntryType.TRANSACTIONEND) {
continue;
}
CanalEntry.RowChange rowChange = CanalEntry.RowChange.parseFrom(entry.getStoreValue());
String tableName = entry.getHeader().getTableName();
if(log.isDebugEnabled()){
CanalEntry.EventType eventType = rowChange.getEventType();
log.debug("数据变更详情:来自binglog[{}.{}],数据源{}.{},变更类型{}",
entry.getHeader().getLogfileName(),entry.getHeader().getLogfileOffset(),
entry.getHeader().getSchemaName(),tableName,eventType);
}
factKeys.add(tableMapKey.get(tableName));
}
for(String key : factKeys){
//删除缓存数据
if(StringUtils.isNotEmpty(key)) redisOpsExtUtil.delete(key);
}
connector.ack(batchId); // 提交确认,不提交会重复获取
log.info("本次[{}]处理Canal同步数据完成",batchId);
}
}
} catch (Exception e) {
log.error("处理Canal同步数据失效,请检查:",e);
}
}
3、redis配置序列化
StringRedisTemplate继承自RedisTemplate,默认采用的是String的序列化策略(支持中文显示),RedisTemplate默认采用的是JDK的序列化策略(中文乱码)。
后续再完善。。。