Redis的发布和订阅(pub/sub)是一种消息通信模式
:发送者(pub)发送消息,订阅者(usb)接收消息
。
Redis 客户端可以订阅任意数量的频道。
用两个客户端,演示一下。
注意:发布的消息没有持久化
,如果是这条消息发出之后才订阅此频道的客户端,不会收到之前的消息。
设置key过期时间的命令有4个:
区别就是精确到秒或毫秒,精确到某一时间戳还是几秒之后
也可以在插入key时就设置过期时间:
区别就是精确到秒或毫秒
查看某个key的剩余存活时间:
取消某个key的过期时间:
Redis有一个过期字典(expires dict),保存了所有key的过期时间。
每次对某个key设置了过期时间,就会把这个key连带它的过期时间,存入过期字典中。
过期字典的数据结构
字典实际上是哈希表,可以做到快速查找。
判断key过期的流程
Redis的过期策略需要考虑两个问题:
做法:
优点:
缺点:
做法:
优点:
缺点:
做法:
优点:
缺点:
三种策略都有优缺点,没有孰优孰劣。
Redis的选择是,组合使用 “懒惰删除” + “定期删除”,以求在内存占用和CPU占用之间取得平衡。
redis.clients
jedis
3.2.0
禁用Linux的防火墙:Linux(CentOS7)里执行命令
systemctl stop/disable firewalld.service
redis.conf中注释掉bind 127.0.0.1 ,然后 protected-mode no
直接连虚拟机的ip,是连不上的,所以还是需要做端口转发。
之后,访问127.0.0.1的2334端口,就能访问到Redis服务器。
编写一个测试程序
public class JedisDemo1 {
public static void main(String[] args) {
//创建一个Jedis对象,传入redis主机的ip地址和端口号
Jedis jedis = new Jedis("127.0.0.1", 2334);
String pong = jedis.ping();
System.out.println("pong = " + pong);
jedis.close();
}
}
启动后,收到pong,证明连接正常。
public class jedisTest1 {
Jedis jedis;
@Before
public void before(){
jedis = new Jedis("127.0.0.1", 2334);
}
@After
public void close(){
jedis.close();
}
@Test
//操作key
public void test1(){
//添加数据
jedis.set("k1", "v1");
jedis.set("k2", "v2");
jedis.set("k3", "v3");
//查看所有key
Set keys = jedis.keys("*");
for (String key : keys) {
System.out.println("key = " + key);
}
//根据键获取值
String k2 = jedis.get("k2");
System.out.println("k2 = " + k2);
//判断键是否存在
Boolean k1 = jedis.exists("k1");
System.out.println("k1 = " + k1);
}
}
要求:
1、输入手机号,点击发送后随机生成6位数字码,2分钟有效
2、输入验证码,点击验证,返回成功或失败
3、每个手机号每天只能获取3次验证码
分析
随机六位数字,可以用random实现。
验证码两分钟内有效,可以将生成的数字码放入redis中,设置过期时间为两分钟(120秒)
验证码是否正确:获取输入,与redis中的进行比较
每个手机号每天只能获取3次验证码,利用redis的incr操作,每次存入验证码后,值+1。当值>2,就提示不能继续发送。
代码实现
/**
* 模拟验证码
* @Author: Crucis_chen
* @Date: 2021/10/31 16:39
*/
public class checkCode {
public static void main(String[] args) {
System.out.println("请输入手机号:");
Scanner scanner = new Scanner(System.in);
String tel = scanner.nextLine();
while (true){
System.out.println("------------------");
System.out.println("获取到的验证码是:"+check(tel));
System.out.println("------------------");
System.out.println("请输入验证码:");
String code = scanner.nextLine();
System.out.println("------------------");
if (verify(code)){
System.out.println("输入正确");
}else{
System.out.println("输入错误");
}
}
}
//生成一个六位的数字验证码
public static String getCode() {
Random random = new Random();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 6; i++) {
sb.append(random.nextInt(10));
}
return sb.toString();
}
//每天只能发送三次,验证码存入redis,并设置过期时间
public static String check(String tel) {
//连接redis
Jedis jedis = new Jedis("127.0.0.1", 2334);
//拼接key
//存储发送次数的key
String countKey = "CheckCode" + tel + ":count";
//存储验证码的key
String codeKey = "CheckCode";
//限制每个tel每天只能发送三次
String count = jedis.get(countKey);
if (count == null) {
//没有获取过
//设置发送次数为1,过期时间为1天
jedis.setex(countKey, 24 * 60 * 60, "1");
} else if (Integer.parseInt(count) <= 2) {
//发送次数加1
jedis.incr(countKey);
} else {
//已经发送了三次,不再发送
System.out.println("警告:已经发送了三次,请一天后再尝试获取");
jedis.close();
}
//将验证码存入redis
String vcode = getCode();
jedis.setex(codeKey, 60 * 2, vcode);
jedis.close();
return vcode;
}
//校验验证码
public static boolean verify(String code){
//连接redis
Jedis jedis = new Jedis("127.0.0.1", 2334);
String checkCode = jedis.get("CheckCode");
return checkCode.equals(code);
}
}
org.springframework.boot
spring-boot-starter-data-redis
org.apache.commons
commons-pool2
2.6.0
spring:
redis:
host: 127.0.0.1 #Redis服务器地址
port: 2334 #Redis服务器连接端口
database: 0 #Redis数据库索引(默认为0)
timeout: 1800000 #连接超时时间(毫秒)
lettuce:
pool:
max-active: 20 #连接池最大连接数(使用负值表示没有限制)
max-wait: -1 #最大阻塞等待时间(负数表示没限制)
max-idle: 5 #连接池中的最大空闲连接
min-idle: 0 #连接池中的最小空闲连接
@EnableCaching
@Configuration
public class RedisConfig extends CachingConfigurerSupport {
@Bean
public RedisTemplate redisTemplate(RedisConnectionFactory factory) {
RedisTemplate template = new RedisTemplate<>();
RedisSerializer redisSerializer = new StringRedisSerializer();
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
jackson2JsonRedisSerializer.setObjectMapper(om);
template.setConnectionFactory(factory);
//key序列化方式
template.setKeySerializer(redisSerializer);
//value序列化
template.setValueSerializer(jackson2JsonRedisSerializer);
//value hashmap序列化
template.setHashValueSerializer(jackson2JsonRedisSerializer);
return template;
}
@Bean
public CacheManager cacheManager(RedisConnectionFactory factory) {
RedisSerializer redisSerializer = new StringRedisSerializer();
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
//解决查询缓存转换异常的问题
ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
jackson2JsonRedisSerializer.setObjectMapper(om);
// 配置序列化(解决乱码的问题),过期时间600秒
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofSeconds(600))
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jackson2JsonRedisSerializer))
.disableCachingNullValues();
RedisCacheManager cacheManager = RedisCacheManager.builder(factory)
.cacheDefaults(config)
.build();
return cacheManager;
}
}
固定化的写法,可以直接copy
测试
@Controller
public class RedisController {
@Autowired
RedisTemplate redisTemplate;
@GetMapping("/redis")
@ResponseBody
public String redisTest(){
redisTemplate.opsForValue().set("msg", "hello,redis!");
String msg = (String) redisTemplate.opsForValue().get("msg");
return msg;
}
}
自动注入一个RedisTemplate对象,利用它操作Redis。
Redis中的事务和MySQL中的不一样。
Redis事务是一个单独的隔离操作
:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。
Redis事务的主要作用就是:串联多个命令,防止别的命令插队。即确保命令的执行顺序
。
Multi、Exec、discard是操作事务的三个命令。
Multi:从输入Multi命令开始,输入的命令都会依次进入命令队列中,但不会执行;(开启事务
)
Exec:直到输入Exec后,Redis会将之前的命令队列中的命令依次执行。(提交事务
)
discard:组队的过程中可以通过discard来放弃组队。 (取消事务,化整为零
)
Multi之后,输入的命令会按顺序排好,暂不执行。
Exec之后,之前的命令依次执行。
在Multi之后,如果想退出组队,可以输入discard
1. 正常的提交事务操作
2. 开启事务后关闭
3. 组队阶段出错
如果组队阶段有错误,提交时整个命令队列都不会被执行。
这种错误通常是语法错误,一般不常见
4. 提交阶段报错
如果执行时有错误,正确的指令依然会被提交,错误指令执行不成功。
组队时,某个命令出现了报告错误,执行时整个的所有队列都会被取消。
执行时,某个命令报出了错误,则只有报错的命令不会被执行,而其他的命令都会执行,不会回滚
。
悲观锁(Pessimistic Lock),顾名思义,就是很悲观,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁
,这样别人想拿这个数据就会block,直到它拿到锁。
传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁
乐观锁(Optimistic Lock),顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据
,可以使用版本号等机制。
乐观锁适用于多读的应用类型,这样可以提高吞吐量
。
Redis就是利用这种check-and-set机制实现事务的。
watch
Redis利用watch保证了隔离性。
在执行multi之前
,先执行watch key1 [key2],可以监视一个(或多个) key 。
如果在事务执行之前,这个(或这些) key 被其他命令所改动,那么事务将被打断。
比如一个事务要对k1操作,执行了watch k1,再执行了multi开启事务。在提交事务之前,另一个客户端对key1进行了修改,那么这个事务就不会执行。
这里就是一个“乐观锁”的操作。被watch的key,每次修改都会检查版本号。
unwatch
取消 WATCH 命令对所有 key 的监视
。
如果在执行 WATCH 命令之后,EXEC 命令或DISCARD 命令先被执行了的话,那么就不需要再执行UNWATCH 了。
Redis 的事务具备如下特点:
原子性:事务的操作要么全部成功,要么全部失败,不会出现执行一半的情况。
Redis并不具备严格的原子性,它只在某些特定条件下表现出原子性:
隔离性:多个事务并发操作数据时的数据一致问题。
Redis并没有事务隔离级别的概念,所以只考虑在并发场景下,多个尚未提交的事务和单条命令之间会不会产生干扰。
事务组织阶段:
事务提交阶段:
持久性:事务提交之后,对数据做出的修改是永久保存的,不会因为任何故障而丢失数据。
Redis有两种持久化策略:
从满足约束的角度来说,Redis可以保证一致性的。
Redis 提供了两种不同形式的持久化方式:
Redis 每执行一条写操作命令,就把该命令以追加的方式写入到一个文件里。AOF 文件的内容是操作命令。
每次重启Redis,都会先读取AOF日志,逐行执行命令来恢复数据
。这就实现了数据的持久化。
在 Redis 中 AOF 持久化功能默认是不开启的,需要我们修改 redis.conf 配置文件中的以下参数:
注意,Redis是先执行写命令,执行成功后才记录AOF日志的,日志会先存放在内存缓冲区中,根据配置的写回策略持久化进磁盘
。
这样有两个好处:
这样做也有弊端:
Redis 写入 AOF 日志的过程,如下图:
简单来说,写AOF日志分为三个阶段:
Redis执行完写操作命令,会把命令追加到 server.aof_buf 缓冲区
之后会通过系统调用,将aof_buf 缓冲区的数据写入AOF文件。
不过此时数据还没有写入磁盘,而是拷贝到了内核缓冲区 page cache,等待内核将数据写入硬盘
具体内核缓冲区的数据什么时候写入到硬盘,由配置的写回策略决定。
在 redis.conf 配置文件中的 appendfsync 配置项可以有以下 3 种参数可选:
AOF的写回需要考虑两点:
Redis提供了多种写回策略,因为每种策略的考量点不一样。
写回策略是如何实现的
调用 fsync() 函数后,内核会立即将缓冲区的内容写入磁盘。
所以不同的写回策略,其实是在不同的时机调用了 fsync() 函数。
随着写操作越来越多,AOF的文件体积也会越来越大。
如果AOF体积太大,就会出现性能问题。比如Redis重启后需要读取AOF的内容来恢复数据,这个耗时就会很久。
因此,Redis为了避免AOF文件太大,设计了 AOF 重写机制,来压缩 AOF 文件。
AOF重写的发生时机:
由两个参数共同控制。
// 当前AOF文件比上次重写后的AOF文件大小的增长比例超过100
auto-aof-rewrite-percentage 100
// 当前AOF文件的文件大小大于64MB
auto-aof-rewrite-min-size 64mb
AOF重写机制的细节:
发生AOF重写时,读取当前数据库的所有键值对
。(注意不是读取旧的AOF文件)思想:
注意,在AOF重写时,选择了发起一个新的AOF文件开始重写,重写完成后再替换原有的AOF文件。
这种做法的考量是,如果直接操作旧的AOF,那么一旦重写失败或出现问题,旧的AOF文件就被污染了,无法用于之后的数据恢复工作。
采用新发起一个AOF文件的方式,如果重写失败,那么删除当前新创建的这个AOF文件,下次重写时再创建一个即可。
在AOF重写时,新的AOF由后台子进程生成,主进程依然会去追加旧的AOF文件,目的是如果新的AOF出现问题,旧的AOF也体现了当前最新的Redis状态。
写入AOF时,由于写入的内容不太多,所以可以由主进程执行。
但是在进行重写AOF时,涉及到的数据比较多,整个操作非常耗时。如果由主进程来完成,那么会造成严重的阻塞。
所以,Redis 的重写 AOF 过程是由后台子进程 bgrewriteaof 来完成的,有两个好处:
子进程进行 AOF 重写期间,主进程可以继续处理命令请求,从而避免阻塞主进程
这里使用的是子进程而不是线程。
因为如果是使用线程,多线程之间会共享内存,那么在修改共享内存数据的时候,需要通过加锁来保证数据的安全,而这样就会降低性能。
而使用子进程,创建子进程时,父子进程是共享内存数据的,不过这个共享的内存只能以只读的方式。
当父子进程任意一方修改了该共享内存,就会发生「写时复制」,于是父子进程就有了独立的数据副本,就不用加锁来保证数据安全,性能更好。
子进程如何拥有和主进程一样的数据副本?
主进程在通过 fork 系统调用生成 bgrewriteaof 子进程时,操作系统会把主进程的「页表」复制一份给子进程。
而页表记录着虚拟地址和物理地址映射关系,相当于它们使用的是同一块物理内存。
这样一来,子进程就共享了父进程的物理内存数据,这样能够节约物理内存资源,页表对应的页表项的属性会标记该物理内存的权限为只读。
当父进程或者子进程向这个物理内存发起写操作时,CPU 就会触发缺页中断。这个缺页中断是由于违反权限导致的。
之后操作系统会在「缺页异常处理函数」里进行物理内存的复制,并重新设置其内存映射关系,将父子进程的内存读写权限设置为可读写,最后才会对内存进行写操作,这个过程被称为 写时复制(Copy On Write)。
写时复制,顾名思义,在发生写操作的时候,操作系统才会去复制物理内存。这样是为了防止 fork 创建子进程时,由于物理内存数据的复制时间过长而导致父进程长时间阻塞的问题。
但是,父进程依然有两个节点会被阻塞:
回到AOF的后台重写问题。一旦触发AOF的重写,主进程就会创建一个重写子进程,此时父子进程共享物理内存。
重写子进程只会对这个内存进行读操作,重写 AOF 子进程会读取数据库里的所有数据,并逐一把内存数据的键值对转换成一条命令,再将命令记录到一个新的 AOF 文件。
如果在重写AOF时,主进程修改了某个键值对,此时就会发生写时复制,不过只会复制主进程修改的这一部分物理内存,没修改的部分依然是共享状态
。所以如果此时修改的是一个bigkey,那么写时复制会比较久,并且写时复制会阻塞主进程。
还有一个问题。在AOF重写时产生的这个新的AOF文件,必须保证它能反映出当前数据库的最新状态。
如果主进程修改了已经存在的键值对,就会发生写时复制,使得该键值对在主进程和子进程的内存中数据不一致
。
为了解决这个问题,Redis 设置了一个 AOF 重写缓冲区,这个缓冲区在创建 bgrewriteaof 子进程之后开始使用。
在重写 AOF 期间,Redis 执行完一个写命令之后,它会同时将这个写命令写入到 「AOF 缓冲区」和 「AOF 重写缓冲区」
。
这个AOF重写缓冲区的作用是:
重写缓冲区的内容是由主进程写入新的AOF文件的。如果重写缓冲区很大,主进程就会被阻塞很久。
Redis的优化:
进行 AOF 后台重写时,Redis 会创建一组用于父子进程间通信的管道
同时会新增一个文件事件,该文件事件会将写入 AOF 重写缓冲区的内容通过管道发送到子进程
在重写结束后,子进程会通过该管道尽量从父进程读取更多的数据
每次等待可读取事件1ms,如果一直能读取到数据,则这个过程最多执行1000次,也就是1秒。
如果连续20次没有读取到数据,则结束这个过程
思想是,子进程尽量完成大部分工作,以减小重写缓冲区的数据量,这样主进程只需要将很少的数据写入AOF文件,阻塞时间不会太长。
RDB 文件的内容是二进制数据,即某一时刻数据库的实际内存情况。
RDB 恢复数据的效率比AOF高一些。因为它在恢复数据时是将快照文件直接读到内存里,而AOF还需要额外执行命令。
Redis提供了两个命令来生成RDB文件,分别是:
至于读取,每次Redis的服务启动会自动读取RDB文件,没有专门提供加载命令。
Redis也可以通过配置“save point”来实现定期保存RDB文件:
save 900 1
save 300 10
save 60 10000
注意,RDB是全量快照,每次执行都会把内存中的所有数据记录到磁盘中,属于比较重的操作。
通常设置至少 5 分钟才保存一次快照。
记录的内容不同:
恢复数据的耗时不同:
数据的丢失风险不同:
在服务器发生故障时,RDB丢失的数据会比AOF更多
因为RDB属于全量快照,生成的耗时长,所以不能频繁生成,一般设置为5分钟一次
而AOF一般配置成每秒持久化一次,所以丢失的数据更少。
文件体积与可读性不同:
在执行bgsave时,主进程依然可以正常处理操作命令,也就是说记录快照时数据依然可以被修改。
快照肯定需要一个具体的时间点,进而获得一个具体的内存状态。Redis的策略是,以子进程创建后的时刻作为快照的时间点。
发生写时复制后,子进程记录的还是原本时刻的内存状态
另外,由于记录快照期间存在写时复制,如果主进程修改的共享数据过多,极端情况下它修改了所有数据,那么Redis占用的内存就会暴涨一倍
所以在写操作频繁的场景下,要注意记录快照时的内存变化,防止内存被打满。
修改快照文件名称:
在redis.conf中配置文件名称,默认为dump.rdb
这个设置项在“快照”的区域。
RDB文件的保存路径,也可以修改。默认为Redis启动时命令行所在的目录下
可以修改,例如dir “/myredis/”
RDB的优点:
RDB的缺点:
AOF的优点是丢失数据少,RDB的优点是恢复数据快。
Redis 4.0提出了“混合使用 AOF 日志和内存快照”,也叫混合持久化
开启的方式:修改配置文件项 aof-use-rdb-preamble为yes
aof-use-rdb-preamble yes
这个配置项翻译过来叫,aof使用rdb附加。
混合持久化的工作时机是AOF日志的重写过程时。
当开启了混合持久化时:
为什么这样的效率会更高?
丢失数据更少
恢复数据更快
,因为前半部分是RDB文件,只需要读取到内存中即可,需要执行的AOF命令较少为什么这种方式可以频繁记录RDB快照?
之前不能频繁创建RDB的原因有两个:
混合持久化方式,记录RDB的时机是AOF重写时,它有两个特点:
总之,如果关闭所有的持久化功能,Redis的性能肯定是最好的。
要按照实际场景需要来考虑是否允许冒着丢失一定数据的风险来换取性能,合理配置持久化策略。
如果手动开启了AOF,Redis就用AOF进行恢复
。因为AOF的更新频率通常较高,这样可以少丢数据。RDB是默认开启的
。只会用一种日志来恢复数据
Redis服务启动时会自动载入RDB文件,载入过程中Redis服务被阻塞。
Redis没有专门提供加载RDB文件的命令。
AOF文件不能直接载入,因为它保存的是一堆写命令,需要依次执行一下才能恢复数据。
但是Redis的命令只能在客户端上下文中执行,也就是说,必须由客户端来执行AOF文件保存的这些命令,后台不能直接去执行。
所以Redis服务使用了一个没有网络连接的“伪客户端 fake client”来执行AOF文件保存的写命令
,这个客户端执行命令的效果和普通的网络客户端完全一样。
Redis 读并发 110000 次 /s, 写并发 81000 次 /s 。如果要支撑起更高的读并发,单机很难实现。
使用读写分离的思想来支持更高的读并发,这种设计适合写操作较少的场景。
做法:
主从架构和读写分离的关系
读写分离是一种思想,使用主从架构的方式来实现读写分离,主写从读
如果Redis只部署在一台服务器上,可能会出现这些单点架构的问题:
主从架构的优势:
Redis提供了主从复制模式。
这个模式可以保证数据一致性,并且主从服务器之间是采用读写分离方式的。
主服务器进行读写操作
。在发生写操作时,将命令同步给从服务器从服务器一般是只读
,接收主服务器发来的写命令,保证数据一致性。多台服务器之间,如何确定谁是主服务器、谁是从服务器?
可以使用 replicaof(Redis 5.0 之前使用 slaveof)命令,手动声明主服务器和从服务器的关系
# 服务器 B 执行这条命令
replicaof <服务器 A 的 IP 地址> <服务器 A 的 Redis 端口号>
这样,服务器 B 就会变成服务器 A 的“从服务器”,然后与主服务器进行第一次同步
主从服务器间的第一次同步的过程可分为三个阶段:
第一个阶段 建立连接、协商同步
执行了 replicaof 命令后,从服务器就会给主服务器发送 psync 命令,表示要进行数据同步。
psync 命令包含两个参数,分别是主服务器的 runID 和复制进度 offset。
主服务器收到 psync 命令后,会用 FULLRESYNC 作为响应命令返回给对方。
第一阶段的工作是为了全量复制做准备。
第二阶段 主服务器同步数据给从服务器
先清空自己当前的数据,然后载入 RDB 文件
。注意:
不过这个问题很好解决。主服务器的主进程会把开始生成RDB文件之后执行的所有写操作保存到AOF重写缓冲区中,后续发给从服务器即可。
第三阶段 主服务器发送新的写操作命令给从服务器
主服务器将AOF重写缓冲区中记录的所有写操作发送给从服务器,从服务器执行这些命令。
在现在这个时刻,主从服务器之间的数据就完全一致了。此时第一次同步完成。
主从服务器在完成第一次同步后,双方之间就会维护一个 TCP 连接。
这个TCP连接是长连接,目的是避免频繁的 TCP 连接和断开带来的性能开销
后续,主服务器就一直通过这个TCP连接,向从服务器传播写命令,从服务器收到后就执行这些命令。
上面的这个过程被称为基于长连接的命令传播
,通过这种方式来保证第一次同步后的主从服务器的数据一致性。
注意,是主服务器的AOF重写缓冲区会被同步给从服务器。
如果在第一次同步后,建立的TCP长连接断开了,怎么办?
如果后来网络恢复了,连接重新建立,如何恢复主从之间的数据一致性?
全量复制
。很明显这样的开销太大了。增量复制
的方式继续同步,也就是只会把网络断开期间主服务器接收到的写操作命令同步给从服务器。增量复制主要有三个步骤:
增量复制的问题在于:主服务器如何得知从服务器当前的数据状态,即哪些数据它没有接收到。
设置到两个区域:
这就产生了两个问题:
repl_backlog_buffer被写入的时机?
repl_backlog_buffer为什么设计成环形缓冲区?
这个缓冲区记录的是,发给从服务器的写操作。
它肯定不能无限记录,因为它的用途只是在恢复连接后支持增量复制。所以它是循环写入的,会覆盖之前的值
它的默认大小是 1M,所以在主服务器写入速度远大于从服务器读取速度时,这个缓冲区的内容将很快被覆盖。
如果发生断线重连后,环形缓冲区内找不到从服务器需要的写操作,就会触发非常耗时的全量复制。
所以应该适当调大这个缓冲区的容量,尽量避免全量复制。参考的公式是:
比如,主服务器每秒产生1MB数据,平均5秒恢复连接,环形缓冲区就至少应该设置为5MB。
一般会设置成这个值的2倍,应对一些突发极端情况。
设置的方法:修改配置文件中这个项的值
repl-backlog-size 1mb
增量复制的细节:
主节点和从节点之间,通过心跳监测的机制,来知晓对方是否还活着。
主节点会每隔10秒ping一次从节点,从节点接收到ping后会响应。
网络环境复杂,如何知道复制的数据是否成功送达?
从节点会每隔1秒,将自身的复制偏移量发送给主节点。相当于一个ACK
如果丢失数据,主节点会重新补发数据。
这个补发数据和增量复制还不一样:
Redis的主从复制共有三种模式:
一个主节点,一个从节点。
主节点可读可写,从节点只接收读请求。常用于主节点出现故障时,从节点能够快速顶上
一个主节点,多个从节点。
对于读命令较大的场景,可以把读命令分摊到多个从节点,增加能处理的读并发量
一主多从还有一个好处:可以使用一个从节点专门执行一些比较耗时的读命令,避免慢查询对主节点造成阻塞,而影响服务的稳定性
。
在与从服务器的第一次同步中,主服务器会做两件耗时的操作:
虽然可以由子进程来完成,主进程可以继续执行操作,不过如果从服务器非常多,那么对主服务器的性能影响也是非常显而易见的:
Redis的做法是:
从服务器也可以有自己的从服务器
,它可以从主服务器接收同步数据,并把这些数据同步给自己的从服务器。这样做的好处是:
有效降低主节点负载和需要传送给从节点的数据量
为什么主从库之间的复制要用RDB,不用AOF?
主从架构的高可用问题
主从读写分离的问题
虽然设计思想是主节点读写、从节点只读,不过需要在业务中手动配置,比较麻烦。
不过如果试图对Redis的从节点进行写操作,就会报错,不会执行。
如果写操作的并发很大,单个master的架构就会成为写并发的瓶颈。
可以使用分布式DB的思想来解决,通过数据分片来降低master单机的写并发。
具体做法:主从模式组+多个组集群,每个组都是一套主从架构,不同组存储不同的数据分片,构成集群。
目前的数据分片技术方案有三种:
客户端实现数据分片
客户端自己去计算数据的key在哪台机器上。多数redis客户端库实现了此功能,也叫sharding。
好处:
缺点:
服务器实现数据分片
客户端直接与集群通信,无需考虑数据的存储细节,由集群来计算key对应的机器。
具体流程:
此方式是redis3.0正在实现,Redis 3.0的集群同时支持HA功能,某个master节点挂了后,其slave会自动接管。
通过代理服务器实现数据分片
客户端直接与代理服务器通信,由代理服务器维护集群节点信息,计算出key对应的机器,访问对应节点获取数据,返回给客户端。
在Redis的主从架构中,一般是读写分离的,只有一台master负责写操作。
如果master挂了,会发生两件事:
此时如果要恢复集群服务,需要做几件事情:
什么是哨兵:
哨兵的作用:
它会监测主节点是否存活,如果发现主节点挂了,它就会选举一个从节点成为新的主节点,并且把新主节点的信息通知给从节点和客户端。
哨兵是一个运行在特殊模式下的 Redis 进程,它也是一个节点。它相当于是“观察者节点”,观察的对象是主从节点。
哨兵节点主要负责三件事情:
涉及到三个问题:
哨兵节点如何监控节点
主观下线与客观下线
为了避免误判主节点的存活状态,给主节点设计了两种状态:主观下线、客观下线
客观下线只适用于主节点,从节点没有这种状态
主观下线与客观下线如何减少误判?
主节点的系统压力较大,可能它并没有故障,但是由于系统压力大或者网络拥塞,导致没有按时回应。如果主节点一次不回应就换掉它,属于误判,会带来资源的浪费。
为了减少误判的情况,哨兵在部署的时候不会只部署一个节点,而是用多个节点部署成哨兵集群(最少需要三台机器来部署哨兵集群)
通过多个哨兵节点一起判断,就可以避免单个哨兵因为自身网络状况不好,而误判主节点下线的情况。
同时,多个哨兵的网络同时不稳定的概率较小,由它们一起做决策,误判率也能降低。
主观下线升级为客观下线
通过哨兵集群投票之后,如果判断主节点客观下线了,就需要从哨兵集群中选出一个leader,来执行主从切换。
这个选举leader也是一个投票的过程,投票就需要有候选人。
如果有两个哨兵都标记主节点为客观下线?
它们都是候选人,各自投了自己一票。
比如A先发出投票命令,那么C就会给A投票。后续C收到B的投票命令后,由于没有投票机会,就会拒绝投票。
只会有一个哨兵满足条件,当选为leader,保证不会出现多个leader的情况,即不会脑裂。
为什么哨兵集群至少需要3个节点?
如果集群只有2个哨兵,那么当选为leader就需要2票,并不是1票。因为候选人会投给自己一票,这样可能出现多个leader的情况。
那么,如果一个哨兵挂掉,剩下的另一个哨兵永远无法成为leader,导致哨兵集群无法进行主从切换。
所以,通常至少设置3个哨兵节点,此时当选为leader就需要2票,就算其中一个哨兵挂掉,集群还能正常选举leader。
如果挂了2个,那依然存在问题。此时可以考虑增加哨兵集群的数量,但是需要合理设置quorum,避免产生多个leader。
如何设置哨兵个数和quorum大小
如果quorum太小:
合理策略:
主从故障转移的细节
主从故障转移包括四个过程:
如何在从节点中选出一个作为主节点?
涉及到两个问题:
选取新主节点的标准
首先要过滤网络不好的节点,避免由于网络拥塞频繁替换新产生的主节点。
做法是,过滤掉当前已经下线的节点,并且过滤掉以往网络连接不好的节点。
如何知道哪些节点以往网络连接不好?
剩下的网络好的节点都是可以作为主节点的,进行三轮筛选,只要有一轮胜出,该从节点就直接作为新的主节点。
三轮筛选涉及到:优先级、复制进度、ID号
如何让选定的从节点变为主节点
将从节点指向新主节点
哨兵leader向其余的从节点发送SLAVEOF<新主节点ip><新主节点port>命令,让它们成为新主节点的从节点。
通知客户端主节点已更换
通过Redis的发布订阅机制实现,客户端从哨兵订阅消息。
哨兵规定了很多消息订阅频道,不同频道包含了主从节点切换过程中的不同关键事件,几个常见的事件如下:
主从切换完成后,哨兵就会向 +switch-master 频道发布新主节点的 IP 地址和端口的消息
客户端就可以收到这条信息,然后用这里面的新主节点的 IP 地址和端口进行通信了。
通过订阅指定频道,客户端还可以得知主从节点切换期间的重大事件,便于得知切换进度。
将旧主节点变为从节点
哨兵监测原先的主节点,在它重新上线后,发送SLAVEOF<新主节点ip><新主节点port>命令,让它成为新主节点的从节点。
组成哨兵集群时,也使用了发布订阅机制。
只需要设置主节点的名称、IP、端口和quorum,而不需要提供其他哨兵的任何信息。
sentinel monitor
那么哨兵之间是如何感知对方,组成集群的?
在主从集群中,主节点上有一个名为_sentinel_:hello的频道,不同哨兵就是通过它来相互发现、相互通信的。
哨兵都订阅了该频道,新的哨兵上线后,把自己的IP地址、端口号信息发布到该频道中,其他哨兵就能获取到,进而建立网络连接。
主节点维护了所有它的从节点的信息,所以哨兵会每隔10秒向主节点发送 INFO 命令,来获取所有「从节点」的信息。
哨兵知道所有从节点的连接信息后,就和每个从节点建立连接,对从节点进行持续监控。
适合放入缓存的数据:
缓存的目的:
将数据缓存在服务器本地的内存中。
以前常用Google的Guava Cache,现在常用Caffine,性能更好
优点:
缺点:
一般使用Redis作为分布式缓存
引入Redis作为缓存后,可以支撑起更大的并发数据访问量。
不过,引入了Redis作为缓存,可能引发三大问题:缓存雪崩、缓存击穿、缓存穿透。
为了保证缓存数据和数据库数据的一致性,一般会给Redis中的数据设置过期时间。
那么,如果出现两种情况的其中一种:
那么,就会有大量的请求直接访问数据库,导致数据库的压力骤增,很可能数据库会崩溃宕机,进而造成整个系统崩溃。
这种现象称为“缓存雪崩”。
可见,发生缓存雪崩有两个可能的原因,所以解决方案也不同。
针对大量数据同时过期,常见的策略有:
均匀设置过期时间
避免将大量数据设置成同样的过期时间。应该在设置过期时间时,加上一个随机数,保证数据不会在同一时间过期
这样做可以分散请求,保证不会出现超级高的并发去直接访问数据库
分布式互斥锁
业务线程在处理访问请求时,如果发现需要的数据不在Redis中,就尝试获取互斥锁。
这种做法其实也很好,因为只要有一个线程访问了数据库,它就可以构建缓存,后面的请求就可以正常读取缓存了。
双 key 策略
对缓存数据设置两个key,一个作为主key设置过期时间,而另一个备key永不过期。
后台更新缓存
缓存的键值对不再设置有效期,使其永远不会自然过期,由后台线程来定时更新缓存。
但是这样做有一个问题:
不设置有效期,也不代表该键值对数据会一直留在内存中。当系统内存紧张时,有些缓存数据就会被淘汰
而数据被淘汰,到下一次后台线程更新缓存这期间,读取缓存会返回空值,此时缓存处于失效状态。
有两种解决方案:
这种方案比较灵活,也可以给键设置过期时间,有两种方案:
同时,在业务刚上线时,就应该利用后台线程提前把缓存都构建好,这种行为称为“缓存预热”。
有两种思路来避免Redis的宕机:
服务熔断或请求限流机制
如果Redis已经宕机,可以启动服务熔断机制,暂停业务对缓存服务的访问,直接返回错误信息。
如果该业务比较重要,不能被直接熔断,也必须采取请求限流的措施。
构建Redis的高可用集群
可以通过主从节点的方式来构建高可用的Redis集群,从而最大程度避免Redis宕机。
因为一个Redis集群有很多台宿主机,如果 Redis 缓存的主节点故障宕机,从节点可以切换成为主节点,继续提供缓存服务。
这样就避免了由于 Redis 单点故障宕机而导致缓存雪崩问题,毕竟这么多台主机全部挂掉的概率较低
业务中通常会有一些热点数据,称为hotKey。
如果缓存中的某个热点数据过期了,此时又正好有大量的请求访问该热点数据,这些请求就会直接访问数据库,数据库就很有可能挂掉。
这种现象称为“缓存击穿”。
缓存击穿问题属于缓存雪崩问题的子集,所以有一些雪崩的解决方案同样适用于击穿:
缓存击穿也有自己的特点,即一般是由热点Key过期,正好遇到高并发访问而导致的,可以这样解决:
缓存穿透是最严重的。
因为缓存雪崩或者穿透时,只是缓存中没有数据,数据库中是有数据的。
只要数据库还没挂掉,有一个线程成功查到数据,就能初始化缓存,问题结束。
但是,如果数据库中没有对应的数据,那就无法构建缓存,大量的请求会持续涌入数据库,数据库几乎必死无疑。
这种现象称之为“缓存穿透”。
缓存穿透一般有两种情况会发生:
常见的缓存穿透解决方案有三种:
限制非法请求
应该在API的入口处,优先判断参数是否合理,比如不能存在非法值、请求字段是否存在。如果发现是恶意请求就直接返回,不访问数据库
缓存空值或者默认值
对于线上业务,如果发现确实有些请求会访问不存在的数据,可以暂且在缓存中针对该数据设置一个空值或默认值,避免它访问数据库
可以在下一次版本更新或维护时处理这一问题
快速判断数据是否存在
可以在写入数据库数据时,使用布隆过滤器做个标记。
在用户请求到来时,如果业务线程确认缓存失效,可以通过查询布隆过滤器快速判断数据是否存在。
布隆过滤器由两部分组成:初始值都为0的位图数组、N个哈希函数
布隆过滤器会通过3个操作标记1个数据:
比如下面的布隆过滤器:
布隆过滤器的特点:
初始化缓存的方法,是在缓存未命中时被调用。它会去查找数据库,获得当前的值,并构建缓存。
一旦发生了缓存雪崩或缓存击穿,就有可能有海量的请求由于缓存未命中而调用这个初始化缓存的方法,进而导致并发访问数据库,导致数据库瞬间被冲垮。
所以,可以给初始化缓存的方法上锁,来保证同一时刻只能有一个请求线程去调用初始化缓存,访问数据库,进而构建缓存。
有两种上锁的思路:
锁的粒度有两种情况:
单机部署的场景,虽然不存在逻辑问题,但严重限制了性能。
而且在分布式部署服务时会存在问题:
本地锁只能锁定当前服务进程的线程,而不能实现数据库级别的上锁
。在高并发场景下,就会存在数据出错的问题,比如超卖问题
。超卖问题:
构建缓存的环节被并发执行,是很多服务访问Redis查不到所需的键值对数据,从而自发地访问MySQL。
由于服务之间的本地锁不共享,所以要构建针对服务之间的锁,应该从服务中抽离出来,放在外部来实现
,比如利用Redis。
使用这样的外部规则,就可以使得多个服务之间共享一把锁的状态,进而锁住整个分布式服务系统,所以成为“分布式锁”。
抛开使用锁的具体时机,首先来研究分布式锁的实现。
基本的思想是,构造一个普通的RedisKey作为锁。
存在的问题:
解决方案:
Redis提供了一个命令:setNX,含义是set … if not exist,如果不存在就创建。
Redis的命令是这样的:
set NX
代码实现
public void getRedisDistributedLock() {
// 尝试获取锁
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "123");
System.out.println(lock);
while (!lock) {
// 没有获取到锁
System.out.println(Thread.currentThread().getName() + "没有获取到锁...");
// 延迟一段时间,再次尝试获取锁
try {
sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
if (lock) {
// 如果获取到锁,执行任务
System.out.println(Thread.currentThread().getName() + "执行了任务...");
// 释放锁
redisTemplate.delete("lock");
}
}
缺陷:
作为锁的RedisKey应该设置过期时间。
因为如果一个线程持有锁后,业务出现问题或者Redis宕机,那么别的线程只能一直等待,造成死锁
注意,应该在获取锁时(插入键时)就设置过期时间,而不应该在获取锁之后再去设置过期时间。
因为如果“获取锁”和“设置过期时间”之间出现异常,依然会导致死锁。
在给锁的key设置过期时间后,如果所有服务的线程都使用同一个key,存在以下问题:
如果key过期了,相当于锁被释放。
而如果当前持有锁的线程还没有执行完任务,而有别的线程请求插入key持有锁,就会导致并发执行任务,出现并发安全问题
而之前的线程在执行完之后,会删除key,相当于释放了别的线程创建的锁,继续导致并发安全问题
要解决这个问题,有两种思路:
代码实现
public void getRedisDistributedLock() {
// 1.生成唯一 id
String uuid = UUID.randomUUID().toString();
// 尝试获取锁
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 10, TimeUnit.SECONDS);
System.out.println(lock);
while (!lock) {
// 没有获取到锁
System.out.println(Thread.currentThread().getName() + "没有获取到锁...");
// 延迟一段时间,再次尝试获取锁
try {
sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
if (lock) {
// 如果获取到锁,执行任务
System.out.println(Thread.currentThread().getName() + "执行了任务...");
// 获取当前锁的值
String lockValue = redisTemplate.opsForValue().get("lock");
// 如果和当前的uuid相同,就可以释放锁
if(uuid.equals(lockValue)) {
// 释放锁
redisTemplate.delete("lock");
}
}
}
上面的方案逻辑上已经很优秀了,但还存在一个问题:“获取当前锁的值”和“释放锁”之间不是连续的,可能出现问题。
举个例子:
如何把“获取当前锁的值”和“释放锁”这两个操作做成原子性的?
可以编写一段Lua脚本来实现。因为脚本在Redis内部是连续执行的,不会被中断。
if redis.call("get",KEYS[1]) == ARGV[1]
then
return redis.call("del",KEYS[1])
else
return 0
end
然后重构解锁的代码即可:
// 脚本解锁
String script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
redisTemplate.execute(new DefaultRedisScript(script, Long.class), Arrays.asList("lock"), uuid);
KEYS[1] 对应“lock”,ARGV[1] 对应 “uuid”,含义就是如果 lock 的 value 等于 uuid ,则删除 lock。
之前设计的分布式锁方案已经比较合理了,但只是能最低限度满足需求,并不成熟。
Redisson 是一个在 Redis 的基础上实现的 Java 驻内存数据网格(In-Memory Data Grid)
一个系统,使用MySQL作为DB,使用Redis作为缓存服务。
一个请求会先查询Redis缓存。如果没有查到,就去MySQL查询,初始化缓存。解决了数据库瓶颈
但是,如果要更新数据库的数据,就有可能涉及到更新缓存和数据库的双写,那么双写之间肯定存在一个先后关系。
双写时,是应该先更新缓存,还是先更新数据库?
这种做法在并发更新同一条数据时,会有数据不一致的风险
。
比如请求A和请求B同时更新同一条数据:
此时就出现了数据不一致,因为数据库的真实值是1
同样,在并发更新同一条数据时,会有数据不一致的风险
。
所以,只要涉及到同时更新缓存和数据库,就必然存在先后关系。
在更新数据时,写入数据库,删除缓存。这样,下次读取数据时,发现缓存没有数据,就会从数据库读取数据,初始化缓存。
这个策略叫“Cache Aside”,旁路缓存策略。
这个策略可以细分为“读策略”和“写策略”两个阶段:
其中,旁路缓存的写操作阶段,也存在写入数据库和删除缓存的先后问题。
这种做法也不好。在读写并发的场景下,会出现数据不一致问题
。
举个例子:
产生这种问题的场景
如果并发很低,特别是读并发很低,出现这种问题的几率比较小。
只有在读写操作的并发量较高时会出现
解决方案:延迟双删
针对这种方案在读写并发场景产生的数据不一致问题,有一个解决方案:延迟双删。
这种方案更新数据的思路是:
延迟这一段时间的目的是:
不过这个方案的延迟时间不好把控,只能说整体上减少了数据不一致的时间,但是没办法非常精准有效地避免数据不一致。
理论上来说,这个方案也是有问题的:
但实际情况下,这种问题出现概率并不高。
这个方案相对比较好地解决了数据一致性问题
。
最佳实践是:先更新数据库,再删除缓存。
但是存在一个问题:假如说更新数据库成功了,但删除缓存失败了,那么之后的请求查询到的都是缓存中的旧值。
简单的解决方法是,给缓存加上一个过期时间:
但是这种做法存在一个问题:如果遇上了删除缓存失败的问题,所有的请求必须等到该缓存过期才能获取到最新值,用户体验不好。
有两种解决方案:
重试机制
引入消息队列,将要删除缓存的key放入消息队列中,由消费者来执行删除动作
订阅 MySQL binlog,再操作缓存
如果更新MySQL成功,MySQL就会产生一条binlog日志。
可以通过订阅 binlog 日志,拿到具体要操作的数据,然后再执行缓存删除。阿里巴巴开源的 Canal 中间件就是基于这个实现的
Canal的工作原理:
这两种方案都是可行的,消息队列重试的方案更容易理解。
本质上,它们都是异步操作缓存的,所以有能力保证缓存最终成功被删除。
因为如果是同步删除缓存,如果引入重试机制,就可能导致业务被重试机制一直阻塞,显然不可取。
旁路缓存的好处
更新数据时删除缓存,是一种懒加载的思想
。因为这样缓存只有在下次用到时才会更新。
哪些场景下删除缓存是一种好的解决方案?
有些业务场景,缓存的值并不直接对应某张表的某个字段,而是需要利用多张表的多个字段计算出来,此时更新缓存就很困难
有些情况,缓存的值计算量比较大,更新的代价较高。
此时应该考虑,这个频繁更新维护的缓存是否会被频繁访问。如果不会,不如改成删除策略,需要时再计算。
旁路缓存的缺陷
如果要严格保证数据一致性,那么“先更新数据库,后删除缓存”的做法最好。其他做法都存在高并发下的数据不一致问题,这种做法出问题的概率最小。
但是,频繁删除缓存会导致频繁的缓存未命中
,对于热点业务会成为性能瓶颈。如果要优化,还是要回到同时更新缓存的问题上。
为什么同时更新数据库和缓存会存在并发问题,归根结底:
保证双写一致性常用的解决方案