本文已参与「新人创作礼」活动,一起开启掘金创作之路。
Redis - 缓存设计与性能优化
多级缓存架构
- Nginx 缓存
- JVM内存(Map)缓存
- Redis集群 缓存
缓存设计
缓存穿透
-
查询一个根本不存在的数据,缓存层和存储层都不会命中,通常出于容错的考虑, 如果从存储层查不到数据则不写入缓存层。
-
缓存穿透将导致不存在的数据每次请求都要到存储层去查询, 失去了缓存保护后端存储的意义。
-
造成缓存穿透的基本原因有两个:
- 自身业务代码或者数据出现问题
- 一些恶意攻击、爬虫等造成大量空命中
-
缓存穿透问题解决方案:
-
缓存空对象,并设置过期时间
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; } }
-
布隆过滤器
对于恶意攻击,向服务器请求大量不存在的数据造成的缓存穿透,还==可以用布隆过滤器先做一次过滤==,对于不存在的数据布隆过滤器一般都能够过滤掉,不让请求再往后端发送。==当布隆过滤器说某个值存在时,这个值可能不存在;当它说不存在时,那就肯定不存在。==
-
布隆过滤器就是一个大型的位数组和几个不一样的无偏 hash 函数。所谓无偏就是能够把元素的 hash 值算得比较均匀。
向布隆过滤器中添加 key 时,会使用多个 hash 函数对 key 进行 hash 算得一个整数索引值然后对位数组长度进行取模运算得到一个位置,每个 hash 函数都会算得一个不同的位置。再把位数组的这几个位置都置为 1 就完成了 add 操作。
向布隆过滤器询问 key 是否存在时,跟 add 一样,也会把 hash 的几个位置都算出来,看看位数组中这几个位置是否都为 1,只要有一个位为 0,那么说明布隆过滤器中这个key 不存在。如果都是 1,这并不能说明这个 key 就一定存在,只是极有可能存在,因为这些位被置为 1 可能是因为其它的 key 存在所致。如果这个位数组比较稀疏,这个概率就会很大,如果这个位数组比较拥挤,这个概率就会降低。
这种方法适用于数据命中不高、 数据相对固定、 实时性低(通常是数据集较大) 的应用场景, 代码维护较为复杂, 但是缓存空间占用很少。
-
可以用redisson实现布隆过滤器,引入依赖:
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.6.5</version> </dependency>
-
示例伪代码:
package com.redisson; import org.redisson.Redisson; import org.redisson.api.RBloomFilter; import org.redisson.api.RedissonClient; import org.redisson.config.Config; public class RedissonBloomFilter { public static void main(String[] args) { Config config = new Config(); config.useSingleServer().setAddress("redis://localhost:6379"); //构造Redisson RedissonClient redisson = Redisson.create(config); RBloomFilter<String> bloomFilter = redisson.getBloomFilter("nameList"); //初始化布隆过滤器:预计元素为100000000L,误差率为3%,根据这两个参数会计算出底层的bit数组大小 bloomFilter.tryInit(100000000L,0.03); //将zhuge插入到布隆过滤器中 bloomFilter.add("zhuge"); //判断下面号码是否在布隆过滤器中 System.out.println(bloomFilter.contains("guojia"));//false System.out.println(bloomFilter.contains("baiqi"));//false System.out.println(bloomFilter.contains("zhuge"));//true } }
-
使用布隆过滤器需要把所有数据提前放入布隆过滤器,并且在增加数据时也要往布隆过滤器里放,布隆过滤器缓存过滤伪代码:
-
注意:布隆过滤器不能删除数据,如果要删除得重新初始化数据。
//初始化布隆过滤器 RBloomFilter<String> bloomFilter = redisson.getBloomFilter("nameList"); //初始化布隆过滤器:预计元素为100000000L,误差率为3% bloomFilter.tryInit(100000000L,0.03); //把所有数据存入布隆过滤器 void init(){ for (String key: keys) { bloomFilter.put(key); } } String get(String key) { // 从布隆过滤器这一级缓存判断下key是否存在 Boolean exist = bloomFilter.contains(key); if(!exist){ return ""; } // 从缓存中获取数据 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; } }
缓存击穿(失效)
- 大批量缓存在同一时间失效,可能导致大量请求同时击穿缓存直达数据库,可能会造成数据库瞬间压力过大甚至挂掉
- 解决: 对于这种情况我们在批量增加缓存时最好 ==将这一批数据的缓存过期时间设置为一个时间段内的不同时间。==让过期时间更加均匀的分布
示例伪代码:
String get(String key) {
// 从缓存中获取数据
String cacheValue = cache.get(key);
// 缓存为空
if (StringUtils.isBlank(cacheValue)) {
// 从存储中获取
String storageValue = storage.get(key);
cache.set(key, storageValue);
//设置一个过期时间(300到600之间的一个随机数)
int expireTime = new Random().nextInt(300) + 300;
if (storageValue == null) {
cache.expire(key, expireTime);
}
return storageValue;
} else {
// 缓存非空
return cacheValue;
}
}
缓存雪崩
- 缓存雪崩指的是缓存层支撑不住或宕掉后, 流量会像奔逃的野牛一样, 打向后端存储层。
- 由于缓存层承载着大量请求, 有效地保护了存储层, 但是如果缓存层由于某些原因不能提供服务(比如超大并发过来,缓存层支撑不住,或者由于缓存设计不好,类似大量请求访问bigkey,导致缓存能支撑的并发急剧下降)
- 于是大量请求都会打到存储层, 存储层的调用量会暴增, 造成存储层也会级联宕机的情况。
预防和解决缓存雪崩问题, 可以从以下三个方面进行着手。
- ==保证Redis服务高可用性,比如使用Redis Sentinel或Redis Cluster。集群和哨兵==
- 依赖隔离组件为后端限流熔断并降级。比如使用Sentinel或Hystrix限流降级组件。 限流:使用MQ队列削峰,也是一个措施
- ==使用redission分布式锁,保证相同key只能有一个线程访问数据库,其它线程延时阻塞;== 待第一个线程读到值,写入redis后;其它阻塞线程直接重新查询redis即可
- 提前演练。准确预估流量
热点缓存key相关
==开发人员使用“缓存+过期时间”的策略既可以加速数据读写, 又保证数据的定期更新, 这种模式基本能够满足绝大部分需求。==
1、热点缓存key 重建优化
- 有两个问题如果同时出现, 可能就会对应用造成致命的危害:
- 当前key是一个热点key(例如一个热门的娱乐新闻),并发量非常大。
- 重建缓存不能在短时间完成, 可能是一个复杂计算, 例如复杂的SQL、 多次IO、 多个依赖等。
- 在缓存失效的瞬间, 有大量线程来重建缓存, 造成后端负载加大, 甚至可能会让应用崩溃。
要解决这个问题主要就是要避免大量线程同时重建缓存。
==我们可以利用互斥锁来解决,此方法只允许一个线程重建缓存, 其他线程等待重建缓存的线程执行完, 重新从缓存获取数据即可。==
示例伪代码:
String get(String key) {
// 从Redis中获取数据
String value = redis.get(key);
// 如果value为空, 则开始重构缓存
if (value == null) {
// 只允许一个线程重建缓存, 使用nx, 并设置过期时间ex
String mutexKey = "mutext:key:" + key;
if (redis.set(mutexKey, "1", "ex 180", "nx")) {
// 从数据源获取数据
value = db.get(key);
// 回写Redis, 并设置过期时间
redis.setex(key, timeout, value);
// 删除key_mutex
redis.delete(mutexKey);
}// 其他线程休息50毫秒后重试
else {
Thread.sleep(50);
get(key);
}
}
return value;
}
2、如何保证缓存里都是热点数据?
- 我们主动去刷新缓存key的过期时间:缓存key每命中一次,就会增加一段过期时间。
- 如果是频繁查询的数据,他的过期时间就会比较长
- 如果是冷数据,他到了初始化过期时间后,就会被淘汰掉
缓存与数据库双写不一致-解决方案
在大并发下,同时操作数据库与缓存会存在数据不一致性问题:
- 双写不一致情况
- 读写并发不一致
解决方案:
- 最重要:==给缓存数据加上过期时间,每隔一段时间触发读的主动更新==
- 如果不能容忍数据不一致,==可以通过加 读写锁(Redisson分布式锁) 保证并发;== 读写或写写的时候。按顺序排好队,读读的时候相当于无锁;
- 也可以用阿里开源的 canal ==通过监听数据库的binlog日志及时的去修改缓存,== 但是引入了新的中间件,增加了系统的复杂度。
- 如果数据库抗不住压力,还可以把缓存作为数据读写的主存储, 异步将数据同步到数据库,数据库只是作为数据的备份。
以上我们针对的都是读多写少的情况加入缓存提高性能, 如果写多读多的情况又不能容忍缓存数据不一致,那就没必要加缓存了,可以直接操作数据库
企业实战解决方案:
- ==更新数据库时,双删Redis 缓存值==
- 第一次删:A线程更新数据时,先将Redis中缓存的值删除;
- 然后进行更新数据库操作;
- 如果A事务没提交前,B线程进来读Redis中缓存值,发现没有,查不到;则去查数据库,此时读到的是 旧数据(脏数据),因为A事务还没提交;并且 B事务会将脏数据重写到Redis中;
- 第二次删:A事务提交后,延时几毫秒,再次将Redis中的值删除;
- 以此来保证 Redis与数据库数据一致性;
- 第一次删:A线程更新数据时,先将Redis中缓存的值删除;
- 更新数据库双删缓存 结合 Key的到期时间使用,效果更佳!
Redis开发规范与性能优化
1.键值设计
-
key 设计
-
键值设计
【建议】: 可读性和可管理性 以业务名(或数据库名)为前缀(防止key冲突),用冒号分隔,比如业务名:表名:id
-
命令使用
【建议】:简洁性 保证语义的前提下,控制key的长度
-
客户端使用
【强制】:不要包含特殊字符 反例:包含空格、换行、单双引号以及其他转义字符
-
-
value设计 - 拒绝bigkey
-
bigkey的产生:
- (1) 社交类:粉丝列表,如果某些明星或者大v 不精心设计下,必是bigkey。
- (2) 统计类:例如按天存储某项功能或者网站的用户集合,除非没几个人用,否则必是bigkey。
- ==(3) 缓存类:将数据从数据库load出来序列化放到Redis里,这个方式非常常用;== 需要注意:有的同学为了图方便把相关数据都存一个key下,产生bigkey。
-
bigkey的危害:
-
1.导致redis阻塞
-
2.网络拥塞
-
3. 过期删除时,如果没有使用过期异步删除策略,就会存在阻塞Redis的可能性
-
-
如何优化bigkey
-
1.拆
- big hash:可以讲数据分段存储; big list: list1、list2、...listN
- 如果bigkey不可避免,不要每次把所有元素都取出来
-
(2).选择适合的数据类型。
-
-
-
控制key的生命周期,redis不是垃圾桶。
- ==设置过期时间==
2.命令使用
- 3.【推荐】合理使用select 多业务用多数据库实际还是单线程处理,会有干扰。
- 4.【推荐】使用批量操作提高效率; 注意控制一次批量操作的元素个数(例如500以内,实际也和元素字节数有关)。
- 5.【建议】Redis事务功能较弱,不建议过多使用,可以用lua替代
3.客户端使用
- 避免多个应用使用一个Redis实例
- ==正例:不相干的业务拆分,公共数据做服务化。==
- 【推荐】 使用带有连接池的数据库,可以有效控制连接,同时提高效率,标准使用方式:
- 【建议】 高并发下建议客户端添加熔断功能(例如sentinel、hystrix)
- 【推荐】 设置合理的密码,如有必要可以使用SSL加密访问
- 【建议】 Redis对于过期键有三种清除策略:
- 如果不设置最大内存, 当 Redis 内存超出物理内存限制时,内存的数据会开始和磁盘产生频繁的交换 (swap),会让 Redis 的性能急剧下降。
-
如果不设置最大内存, 当 Redis 内存超出物理内存限制时,内存的数据会开始和磁盘产生频繁的交换 (swap),会让 Redis 的性能急剧下降。
-
==当Redis运行在主从模式时,只有主结点才会执行过期删除策略,然后把删除操作”del key”同步到从结点删除数据。==