文章目录
- 一、java操作Redis集群
- 1.Jedis整合Redis
- 2.SpringBoot整合Redis
- 二、redis缓存解决方案
- 1.redis脑裂
- 2.缓存预热
- 3.缓存穿透
- 4.缓存击穿
- 5.缓存雪崩
- 6.redis开发规范
- 7.数据一致性
- 总结:
一、java操作Redis集群
1.Jedis整合Redis
- 引入jedis依赖
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>4.2.3</version>
</dependency>
- java代码:
JedisCluster jedisCluster;
@Before
public void init()
{
// maxIdle: 链接池中最大空闲的连接数,默认为8.
// minIdle: 连接池中最少空闲的连接数,默认为0.
// 1) 使用工厂模式创建对象;
// 2) 通过"pool配置"来约束对象存取的时机
// 3) 将对象列表保存在队列中(LinkedList)
GenericObjectPoolConfig config = new GenericObjectPoolConfig();
config.setMaxTotal(20);//最大总数
config.setMaxIdle(10);//最大空闲数
config.setMinIdle(5);//最小空闲数
//构建set集合保存redis node
Set<HostAndPort> redisNodes = new HashSet<>();
redisNodes.add(new HostAndPort("192.168.126.13",8001));
redisNodes.add(new HostAndPort("192.168.126.14",8001));
redisNodes.add(new HostAndPort("192.168.126.15",8001));
redisNodes.add(new HostAndPort("192.168.126.13",8002));
redisNodes.add(new HostAndPort("192.168.126.14",8002));
redisNodes.add(new HostAndPort("192.168.126.15",8002));
//构建JedisCluster 建立连接
//connectionTimeout:指的是连接一个url的连接等待时间
//soTimeout:指的是连接上一个url,获取response的返回等待时间
//最大尝试次数
jedisCluster = new JedisCluster(redisNodes,6000, 5000, 10, config);
}
@Test
public void clusterTest()
{
//添加元素
jedisCluster.set("name","乌鸡哥");
//获取元素
String name = jedisCluster.get("name");
System.out.println(name);
}
@After
public void close()
{
//关闭jedis连接
//jedis.close();
jedisCluster.close();
}
2.SpringBoot整合Redis
- 创建一个springboot项目clusterdemo
- 引入Lombok 、Spring Data Reactive Redis依赖
- 在application.properties文件中配置redis集群:
##redis服务器集群
spring.redis.cluster.nodes=192.168.126.13:8001,192.168.126.14:8001,192.168.126.15:8001,192.168.126.13:8002,192.168.126.14:8002,192.168.126.15:8002
## 连接池最大连接数(使用负值表示没有限制)
spring.redis.pool.max-active=300
## Redis数据库索引(默认为0)
spring.redis.database=0
## 连接池最大阻塞等待时间(使用负值表示没有限制)
spring.redis.pool.max-wait=-1
## 连接池中的最大空闲连接
spring.redis.pool.max-idle=100
## 连接池中的最小空闲连接
spring.redis.pool.min-idle=20
## 连接超时时间(毫秒)
spring.redis.timeout=60000
- 在springboot的Test类下进行测试:
@SpringBootTest
class ClusterdemoApplicationTests {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Test
void contextLoads() {
stringRedisTemplate.opsForValue().set("age","18");
String age = stringRedisTemplate.opsForValue().get("age");
System.out.println(age);
}
}
二、redis缓存解决方案
1.redis脑裂
- redis的集群脑裂是指因为网络问题,导致redis master节点跟redis slave结点和sentinel集群处于不同分区,此时因为sentinel集群无法感知master的存在,所以将slave节点提升为master节点。
此时存在两个不同的master节点,就像一个大脑分裂成了两个。集群脑裂问题中,如果客户端还在基于原来的master节点继续写入数据,那么新的master节点将无法同步这些数据,当网络问题解决之后,sentinel集群将原先的master节点降为slave节点,此时再从新的master中同步数据,将会造成大量数据丢失。
即在新的master节点诞生时,原先master节点此时写入的数据将会丢失。 - 解决方案:
在redis.conf中配置参数:min-replicas-to-write 1
,min-replicas-max-lag 5
参数含义:
min-replicas-to-write 1,表示最少的slave节点为1个
min-replicas-max-lag 5,表示数据的复制和同步的延迟不得超过5秒
配置完这两个参数,如果发生脑裂,原master会在客户端写入操作时拒绝请求。这样可以避免大量数据丢失。
2.缓存预热
- 缓存中没有数据,由于缓存冷启动一点数据都没有,如果直接就对外提供服务了,那么并发量上来,mysql就裸奔挂掉了。
- 新启动的系统没有任何缓存数据,在缓存重建数据的过程中,系统性能和数据库负载都不太好,所以最好是在系统上线之前就把要缓存的热点数据加载到缓存中,这种缓存预加载手段就是缓存预热。
- 解决思路
- 提前给redis灌入部分数据,再提供服务
- 如果数据量非常大,就不可能将所有数据都写入redis,因为数据量太大了,第一是因为耗费的时间太长了,第二是redis根据容纳不下所有数据。
- 需要根据当天的具体访问情况,实时统计出访问频率较高的热数据。
- 然后将访问频率较高的热数据写入到redis中,热数据肯定也比较多,也得多个服务并行读取数据去写,并行的分布式的缓存预热
Storm技术可以实时统计计算,也就是实时统计访问次数。nginx通过lua将请求日志发送
给消息中间件,Storm技术对消息中间件的消息进行消费,最后统计出实时访问次数。
3.缓存穿透
- 缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求,如发起id为-1的数据或id为特别大且不存在的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大。
缓存穿透是指用户查询数据,在数据库中没有,在缓存中自然也不会有。这样就导致用户查询时,在缓存中找不到,每次都要去数据库再查询一遍,然后返回空(相当于进行了两次无用的查询)。这样请求就绕过缓存直接查数据库,这也是经常提的缓存命中率问题。 - 解决方案
- 对空值缓存:如果一个查询返回的数据为空(不管数据是否存在),仍然把这个空结果缓存,设置空结果的过期时间会很短,最长不超过5分钟。
- 布隆过滤器:如果想判断一个元素是否在一个集合里,一般想到的是将集合中所有元素保存起来,然后通过比较确定。
- 布隆过滤器
布隆过滤器是一种数据结构,比较巧妙的概率型数据结构(probabilistic data structure),特别是高效地插入和查询,可以用来告诉你 “某样东西一定不存在或可能存在”。
布隆说不存在则一定不存在,说存在则可能不存在。 - 引入hutool依赖:
<!-- https://mvnrepository.com/artifact/cn.hutool/hutool-all -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.5</version>
</dependency>
- java代码实现
@Test
public void filterTest()
{
//初始化 构造方法的参数大小决定了布隆过滤器BitMap的大小
BitMapBloomFilter bloomFilter = new BitMapBloomFilter(10);
//添加元素
bloomFilter.add("123");
bloomFilter.add("abc");
bloomFilter.add("zzx");
boolean zzx = bloomFilter.contains("zzx");
System.out.println(zzx);
}
4.缓存击穿
- 某一个热点key,在缓存过期的一瞬间,同时有大量请求进来,由于此时缓存过期了,所以请求最终会走向数据库,造成瞬时数据库请求量大、压力骤增,甚至可能打垮数据库。
- 解决方案
- 互斥锁:在并发的多个请求中,只有第一个请求线程能拿到锁并执行数据库查询操作,其他线程拿不到锁就阻塞等待,等到第一个线程将数据写到缓存后,其他线程直接查询缓存。
- 热点数据不过期:直接将缓存设置为不过期,然后由定时任务去异步加载数据,更新缓存。
- java代码实现,使用setnx来做互斥锁,并设置过期时间,然后由第一个请求线程对数据库查询操作,再进行缓存,后面就会直接取缓存了。
/**
* 互斥锁解决缓存击穿
*/
@Test
public void lockTest() throws InterruptedException {
String key = "zzx";
//获取key的值
String value = jedisCluster.get(key);
System.out.println(value);
//判断缓存是否过期
if(value == null)
{
//设置3分钟超时。 只有key不存在的时候才会创建(setnx来当互斥锁)
long setnx = jedisCluster.setnx(key + "_mutex", "2");
//设置过期时间
jedisCluster.pexpire(key+"_mutex",3*6000);
//设置成功
if(setnx == 1)
{
//数据库DB操作
value="db";
//保存缓存
jedisCluster.setex(key,3*60,value);
jedisCluster.del(key+"_mutex");
}else
{
Thread.sleep(5000);
lockTest();
}
}
System.out.println(value);
}
因为采用test,所以没有设置方法参数
5.缓存雪崩
- 缓存雪崩是指在设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到数据库,数据库瞬间压力过大雪崩。
- 解决方案
- 过期时间打散:既然是大量缓存集中失效,那最容易想到的就是让它们不集中失效。可以给缓存的过期时间加上一个随机值时间,使得每个key的过期时间分开,不会集中在同一时刻失效。
- 热点数据不过期:和缓存击穿一样,也是要着重考虑刷新的时间间隔和数据异常如何处理的情况。
- 加互斥锁:h和缓存击穿一样,按key的维度加锁,对于同一个key,只允许一个线程去计算,其他线程原地阻塞等待第一个线程的计算结果,然后直接走缓存即可。
- java代码,第一个判断是进去synchronized之前,第二个判断是有可能进去的时候,缓存被其他线程加载了,因为是测试就没有写带参数有返回值的方法:
/**
* 通过加锁 解决缓存雪崩
*/
@Test
public void saveLock()
{
String key = "zhs";
//加锁
String lockKey = key;
//获取key的值
String value = jedisCluster.get(key);
System.out.println(value);
//判断缓存是否过期
if(value == null)
{
synchronized (lockKey)
{
//获取key的value值
String s = jedisCluster.get(key);
if(s!=null){
System.out.println(s);
}else
{
//数据库DB操作
value = "value";
jedisCluster.set(key,value);
}
}
}
System.out.println(value);
}
6.redis开发规范
- key的设计
- 把表名转换为key的前缀,如user:
- 把第二段放置用于区分key的字段,对应mysql中主键的列名,如user_id
- 第三段放置主键值,如1、2、3
- 第四段写存储的列名及对应的值,如name zzx
例如set user:user_id:1:name zzx
这样就可以将这些值进行分层,方便查询缓存
- value的设计
防止网卡流量、慢查询,string类型控制在10kb以内,hash、list、set、zset元素个数不要超过5000。 - 命令使用:
- 禁用命令
禁止线上使用keys,flushall、flushdb等,通过redis的rename机制禁掉命令,或者使用scan的方式渐进式处理。 - 合理使用select
redis的多数据库较弱,使用数字进行区分,很多客户端支持较差,同时多业务用多数据库实际还是单线程处理,会有干扰。 - 使用批量操作提高效率
原生命令:mget、mset。
非原生命令:可以使用pipeline提高效率。
但要控制一次批量操作的元素个数(如500个以内,实际也和元素字节数有关) - 不建议过多使用redis事务功能
redis的事务功能较弱(不支持回滚),而且集群版本要求一次事务操作的key必须在一个slot上。
- 客户端使用
- Jedis :https://github.com/xetorthio/jedis 重点推荐
- Spring Data redis :https://github.com/spring-projects/spring-data-redis 使用Spring框架时推荐
- Redisson :https://github.com/mrniko/redisson 分布式锁、阻塞队列时重点推荐
- 避免多个应用使用一个redis实例
不相干的业务进行拆分,公共数据做服务化。 - 使用连接池
可以有效控制连接,同时提高效率。
7.数据一致性
从理论上讲,给缓存设置过期时间,是保证最终一致性的解决方案。
- 三种更新策略
- 先更新数据库,再更新缓存
线程安全角度,请求A与请求B同时进行更新操作,会出现
(1)线程A更新了数据库
(2)线程B更新了数据库
(3)线程B更新了缓存
(4)线程A更新了缓存
这就出现请求A更新缓存应该比请求B更新缓存早才对,但是因为网络等原因,B却比A早更新了缓存。这就导致了脏数据,因为不考虑
即B更新完后,A因为网络原因,比B晚更新缓存。出现数据库是B,缓存是A - 先删除缓存,再更新数据库
请求A进行更新操作的同时,请求B进行查询操作,会出现
(1)请求A进行写操作,删除缓存
(2)请求B查询发现数据不存在
(3)请求B去数据库查询得到旧值
(4)请求B将旧值写入缓存
(5)请求A将新值写入数据库
该数据永远都是脏数据
即A删除缓存,B又去数据库加载缓存,A更新数据库。出现数据库是A,缓存是B(旧值) - 先更新数据库,再删除缓存
请求B进行更新操作的同时,请求A进行查询操作,会出现
(1)缓存刚好失效
(2)请求A查询数据库,得到一个旧值
(3)请求B将新值写入数据库
(4)请求B删除缓存
(5)请求A将查询到的旧值写入缓存
即缓存失效A查询数据库,B更新完操作后,A将旧值写入缓存。出现数据库是B,缓存是A(旧值)
这种情况,需要步骤3的写数据库操作比步骤2的读数据库操作耗时更短,才有可能使步骤4先于步骤5。读操作更快于写操作,所以很难出现这种情况。
采用延时删除缓存可以有效的避免出现这种情况。
总结:
- 在java操作redis时,集群跟单机除了配置文件不同,其余操作相同。
- redis的集群脑裂是指因为网络问题,sentinel集群无法感知master的存在,将slave提升为master,此时有两个master(即脑裂),通过配置min-replicas-max-lag命令来解决。
- 缓存预热就是在系统上线之前就把热点数据加载到缓存中。主要解决缓存冷启动问题。
- 缓存穿透是指用户不断发起请求缓存和数据库中都不存在的数据。可以通过对空值缓存或者使用布隆过滤器(概率型数据结构)来解决。布隆过滤器的底层是bit数组,此时布隆过滤器的bool值为false则,这个值一定不存在,true则有可能不存在。
- 缓存击穿是指缓存过期的一瞬间涌入大量请求,对数据库造成冲击甚至击垮。
可以使用加互斥锁或设置热点数据不过期来解决。 - 缓存雪崩是指大批量的缓存在瞬间同时失效,从而对数据库造成的压力过大。可以将缓存数据的过期时间设置为随机、设置热点数据不过期和加互斥锁来解决。
- 对key插入时进行一个分层,这样可以高效的查询缓存。value的值不要太大,单个数据类型的元素不要太多(不超过5000),批量操作可以使用pipeline,使用redis线程池。
- redis先更新数据库,再延时删除缓存并且设置缓存的过期时间是保证数据一致性最合理的方案。