InterviewQuestions

一、Redis篇

一、使用场景

常用场景

  • 缓存

    • 穿透、击穿、雪崩

    • 双写一致、持久化

    • 数据过期、淘汰策略

  • 分布式锁

    • setnx

    • redisson

  • 计数器

  • 保存token

    • 数据类型

      • string

  • 消息队列

    • 数据类型

      • list

  • 延迟队列

    • 数据类型

      • zset

一、Redis的使用场景

  • 根据自己简历上的业务进行回答

  • 缓存

    • 穿透、击穿、雪崩

    • 双写一致、持久化

    • 数据过期、淘汰策略

  • 分布式锁

    • setnx

    • redisson

二、什么是缓存穿透,怎么解决

缓存穿透

缓存穿透查询一个不存在的数据,mysql查询不到数据也不会直接写入缓存,就会导致每次请求都查数据库

解决方案一:缓存空数据

缓存空数据,查询返回的数据为空,仍把这个空结果进行缓存

  • 优点:简单

  • 缺点:消耗内存,可能会发生不一致的问题 (一开始数据库没有数据,缓存也添加上了为null,但是后面这个这个数据真的有了,缓存中还是为null,就会造成缓存和数据库不一致的问题)

解决方案二:布隆过滤器

在缓存预热的时候,需要往布隆过滤器中添加数据,请求的时候先请求布隆过滤器判断id是否存在,如果存在的话就直接放行进行查redis或者数据库,不存在就拦截直接返回

布隆过滤器

  • 优点:内存占用较少,没有多余key

  • 缺点:实现复杂,存在误判

什么是布隆过滤器?

bitmap(位图):相当于是一个以(bit)位 为单位的数组,数组中每个单元只能存储二进制数0或1

布隆过滤器主要实现就是依赖于这个bitmap

布隆过滤器作用:布隆过滤器可以用于检索一个元素是否在一个集合中。

  • 如果都为1,就证明这个值是存在的。如果有一个不是1,就证明不存在

  • 位图的好处就是,存储压力较小,因为只需要存储二进制数据0和1

  • 所以我们在预热数据的时候,就可以把缓存中的所有id,在经过hash计算后存储到bitmap中。比如当有id为空的时候,就可以使用布隆过滤器来过滤那些不存在的请求

注意就是,使用布隆过滤器有可能存在误判

误判率:数组越小误判率就越大,数组越大误判率就越小,但是同时带来了更多的内存消耗。

在具体的实现方案中,这个误判率其实我们是可以控制的

布隆过滤器实现方案

  • Redisson

  • Guava

这两者都提供了对布隆过滤器具体的实现

/** 
 * @description 测试误判率
 * @param bloomFilter
 * @param size
 * @return
 */
private static int getData(RBloomFilter<String> bloomFilter, int size) {
	int count = 0; //记录误判的数据条数
	for(int x = size; x < size * 2; x++) {
		if(bloomFilter.contains("add" + x)){
			count++;
		} 
	}
	return count;
}

/** 
 * @description 初始化数据
 * @param bloomFilter
 * @param size
 */
private static void initData(RBloomFilter<String> bloomFilter, int size) {
	//第一个参数:布隆过滤器存储的元素个数,第二个参数:误判率
	bloomFilter.tryInit(size, 0.05);
	//在布隆过滤器初始化数据
	for(int x = 0; x < size; x++) {
		bloomFilter.add("add" + x);
	}
	System.out.println("初始化完成...");
}

0.05就是5%的误判,大部分公司项目都是能接受的,不至于在高并发下压倒数据库

总结:

1. Redis的使用场景

  • 根据自己简历上的业务进行回答

  • 缓存

    • 穿透、击穿、雪崩、双写一致、持久化、数据过期、淘汰策略

  • 分布式锁

    • setnx、 redisson

2. 什么是缓存穿透,怎么解决

  • 缓存穿透:查询一个不存在的数据,mysql查询不到数据也不会直接写入缓存,就会导致每次请求都查数据库

  • 解决方案一:缓存空数据

  • 解决方案二:布隆过滤器

参考回答:

三、什么是缓存击穿,怎么解决

缓存击穿:给某一个key设置了过期时间,当key过期的时候,恰好这时间点对这个key有大量的并发请求过来,这些并发的请求可能会瞬间把DB压垮

缓存重建的过程中消耗的时间内,数据库遭受大量并发请求,可能就会瞬间压垮了

解决方案一:互斥锁(分布式锁)

这种方案能保证数据强一致性,但是性能差

解决方案二:逻辑过期

多添加一个过期时间的字段,请求过来的时候先判断过期时间是否过期,如果过期了就获取互斥锁重建缓存数据,在缓存数据没有重建完成之前,所有请求都先返回过期数据。缓存重建后新的请求过来就会返回没有过期的数据

这种方案高可用,性能优,但是不能保证数据绝对一致

需要根据业务选择不同方案:

  • 一般涉及金钱方面的业务,一般情况下都需要保证强一致性,就选择互斥锁

  • 如果一般业务更注重用户体验,像一些互联网企业,更多的选择是高可用型

总结:

缓存击穿

  • 缓存击穿:给某一个key设置了过期时间,当key过期的时候,恰好这时间点对这个key有大量的并发请求过来,这些并发的请求可能会瞬间把DB压垮

  • 解决方案一:互斥锁,强一致,性能差

  • 解决方案二:逻辑过期,高可用,性能优,不能保证数据绝对一致

参考回答:

四、什么是缓存雪崩,怎么解决

缓存雪崩是指在同一个时间段大量的缓存key同时失效或者redis服务宕机,导致大量请求到达数据库,代理巨大压力。

解决方案:

  • 1.给不同的key的TTL添加随机值

    • 比如可以在原有的失效时间的基础上,增加一个随机值,给个1-5分钟的随机值,这样每个缓存的过期时间重复率就会降低,就很难引发集体失效的事件

  • 2.利用redis集群提高服务的可用性

    • 哨兵模式、集群模式

  • 3.给缓存业务添加降级限流策略

    • nginx或spring cloud gateway

  • 4.给业务添加多级缓存

    • Guava或Caffeine,可以使用它们作为一级缓存,redis作为二级缓存,就可以预防缓存雪崩的问题了

总结

1.缓存雪崩是指在同一时段大量的缓存key同时失效或者redis服务宕机,导致大量请求到达数据库,代理巨大压力。

2.解决方案

  • 给不同的key的TTL添加随机值

  • 利用redis集群提高服务的可用性

  • 给缓存业务添加降级限流策略

    • 降级可作为系统的保底策略,适用于穿透、击穿、雪崩

  • 给业务添加多级缓存

参考回答:

五、redis双写问题

redis作为缓存,mysql的数据如何与redis进行同步呢?(双写一致性)

一定、一定、一定要设置前提,先介绍自己的业务背景

  • 一致性要求高

  • 允许延迟一致

双写一致

双写一致性:当修改了数据库的数据也要同时更新缓存的数据,缓存和数据库的数据要保持一致

  • 读操作:缓存命中,直接返回;缓存未命中查询数据库,写入缓存,设定超时时间

  • 写操作:延迟双删

问题:

先删除缓存,还是先修改数据库

其实无论先操作哪一步,都会出问题,如下分析

1、先删除缓存,还是先修改数据库
1.先删除缓存,在操作数据库

如图缓存数据和数据库的数据都是10

正常情况

线程1进来先删除缓存,然后更新数据库

然后线程2进来,查询缓存没有,就会查询数据库,然后把数据库的20写入到缓存。这是一种正常的情况,没有出现任何问题

不正常情况

还是同样的数据,线程1进来的时候先删除缓存,但是因为线程是交替执行的,谁抢到时间片谁先执行,这时候线程2进来了

如上图,线程2 进来之后,先去查询缓存,缓存没有命中,就去查询数据库,这时候数据库的值还是10,就把这个10写入了缓存,线程2 就执行结束了

然后再回到线程1继续执行,但是线程1更新数据库的值是20,此时缓存中已经写入了10。所以这样数据库和缓存的值就不一致了

2.先操作数据库,再删除缓存

还是同样的数据

正常情况

线程2进来先更新数据库,然后删除缓存,就完事了

然后线程1进来,查询缓存未命中,查询数据库是20,把这个20写入缓存,这是正常的流程

不正常情况

线程1先进来,在查询缓存的时候,这时候缓存已经过期了,那么就会去直接查数据库,数据库此时的数据是10,线程先把这个10读到了。

但是,在线程1还没有将这个10写入到缓存的时候,线程2进来了,此时线程2直接到数据库更新了数据,将数据库数据更新到了20,然后删除缓存(此时缓存没有数据,删不删都是一样的),然后线程2 执行结束了

然后再回到线程1执行,线程1此时已经把旧的数据库数据10读到了,下面的操作就要去将这个10写入到缓存中。此时缓存和数据库的数据又不一致了

2、为什么要删除两次缓存

如上面所说,先删除缓存再删除数据库,肯定是会有脏数据的情况的,所以采用了双删的策略,让数据库修改完成以后再删除一次缓存,降低脏数据的出现

3、为什么要延时双删

因为一般情况下,数据库是主从模式,它是读写分离的,我们需要延时一会,让主节点把数据同步到从节点,所以需要延时

但是这个延时也可能出现问题,因为延时多少时间不好控制,在延时的这个过程中,依然有可能出现脏数据。

所以综上所述,延时双删极大的控制了脏数据的风险,但是只是控制了一部分,仍然会有脏数据的风险,它是做不到绝对的强一致的

如何才能保证强一致性呢

可以采用互斥锁的方式,就是在写数据或者是读数据的时候,我们去添加一个分布式锁,这个就能绝对的保证数据的一致性,但是性能就非常低了

其实,我们可以稍微的优化一下,在保证数据绝对一致的前提下,性能也比较好

我们先要强调一下,像这些存入缓存的数据,一般都是读多写少的,如果是写多读少的,一般不建议把数据存入到缓存中,直接操作数据库更省事。

我们可以利用读写锁来进行控制

  • 共享锁:读锁readLock,加锁之后,其它线程可以共享读操作。就是其它线程可以继续读数据,但是不能进行写操作

  • 排他锁:也叫独占锁writeLock,加锁之后,阻塞其它线程读操作

具体代码实现:

读操作代码

public Item getById(Integer id){
	RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("ITEM_READ_WRITE_LOCK");
	//读之前加读锁,读锁的作用就是等待该lockkey释放写锁以后再读
	RLock readLock = readWriteLock.readLock();
	try {
		//加锁
		readLock.lock();
		System.out.println("readLock...");
		Item item = (Item) redisTemplate.opsForValue().get("item:"+id);
		if(item != null){
			return item;
		}
		//查询业务数据
		item = new Item(id, "华为手机", "华为手机", 5999.00);
		//写入缓存
		redisTemplate.opsForValue().set("item:"+id, item);
		//返回数据
		return item;
	} finally {
		readLock.unLock();//解锁
	}
}

写操作代码

public void updateById(Integer id) {
	RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("ITEM_READ_WRITE_LOCK");
	//写之前加写锁,写锁加锁成功,读锁只能等待
	RLock writeLock = readWriteLock.writeLock();
	try {
		//加锁
		writeLock.lock();
		System.out.println("writeLock...");
		//更新业务数据
		Item item = new Item(id, "华为手机", "华为手机", 5299.00);
		try {
			Thread.sleep(10000);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		//删除缓存
		redisTemplate.delete("item:" + id);
	} finally {
		writeLock.unlock();//释放锁
	}
}

注意:读锁和写锁前缀key要保持一致,不然无法控制

使用读写锁的方式,可以保证强一致的数据同步,但是性能肯定是不高的,因为在写数据的时候,依然会阻塞其它线程去读数据。

所以说读写锁的方式适合于业务必须要求数据强一致的情况下

允许短暂的不一致

其实这种在实际的开发中更为主流的,大部分情况下都是运行稍微延迟的,这种的解决方案就有比较多了

1、基于MQ异步通知保证数据的最终一致性:

如图所示,当我们修改数据,写入到数据库的时候,就会发一条消息给MQ,在缓存服务这端,需要去监听这个MQ,最终再去更新缓存即可

当然,消息发出以后,什么时候接收到消息,什么时候更新到缓存,这个肯定也是有延迟的,但是它能保证数据的最终一致性

2、基于Canal的异步通知:

Canal是阿里的一个中间件,是基于mysql的主从同步来实现的

当有数据修改的时候,写入了数据库,数据库一旦发生了变化,就会把这个变化,记录到binlog的日志文件中,当有我们需要的表数据发生了变化之后,我们就可以在缓存服务这块获取变化之后的数据,然后去更新缓存。

这种方式的好处就是,对于业务代码几乎是零嵌入的。如果业务可以接收短暂延时,Canal这种方式是比较不错的选择

二进制日志(BINLOG)记录了所有的DDL(数据定义语言)语句和DML(数据操纵语言)语言,但不包括数据查询(SELETE、SHOW)语句。

总结:

redis作为缓存,mysql的数据如何与rdis进行同步呢?(双写一致性)

  1. 介绍自己简历上的业务,我们当时是把文章的热点数据存入到了缓存中,虽然是热点数据,但是实时要求性并没有那么高,所以,我们当时采用的是异步的方案同步的数据

  2. 我们当时是把抢券的库存存入到了缓存中,这个需要实时的进行数据同步,为了保证数据的强一致,我们当时采用的是redisson提供的读写锁来保证数据的同步

那你来介绍一下异步的方案(你来介绍一下redisson读写锁的这种方案)

  • 允许延时一致的业务,采用异步通知

    • ① 使用MQ中间件,更新数据之后,通知缓存删除(需要保证MQ的可靠性)

    • ② 利用Canal中间件,不需要修改业务代码,伪装为mysql的一个从节点,canal通过读取binlog数据更新缓存

  • 强一致性的,采用Redisson提供的读写锁

    • ① 共享锁:读锁readLock,加锁之后,其它线程可以共享读操作

    • ② 排他锁:也叫独占锁writeLock,加锁之后,阻塞其它线程读操作

参考回答:

强一致性业务场景

允许延时一直的业务场景

六、Redis持久化问题

redis作为缓存,数据的持久化是怎么做的呢?

在redis中提供了两种数据持久化的方式

1、RDB

2、AOF

1、RDB

RDB全称Redis Database Backup file (Redis数据备份文件),也被叫做Redis数据快照。简单来说就是把内存中的所有数据都记录在磁盘中。当Redis实例故障重启后,从磁盘读取快照文件,恢复数据

Redis内部有触发RDB的机制,可以在redis.conf文件中找到,格式如下:

# 900秒内,如果至少有1个key被修改,则执行bgsave
save 900 1
save 300 10
save 60 10000

表示900秒内,如果有1个key被修改,则执行bgsave

下面两个也是类似的意思,可以根据自己的需要进行适当的配置

RDB的执行原理

bgsave开始时会fork主进程得到子进程,子进程共享主进程的内存数据。完成fork后读取内存数据并写入RDB文件。

1.如下图,有一个物理内存,可以理解为计算机的内存条。和redis的主进程

2.redis主进程要实现对redis数据的读写操作,肯定需要在内存中去操作,但是在linux系统中,所有的进程都无法直接操作物理内存的

3.操作系统为每个进程分配了一个虚拟内存,主进程只能操作虚拟内存。操作系统会维护一个虚拟内存和物理内存的映射关系表,叫做页表

4.主进程去操作虚拟内存,虚拟内存基于页表的映射,关联到物理内存真正存储数据的位置,这样就能实现对物理内存读和写的操作了

5.主进程和子进程是如何同步数据的呢?首先我们知道,当我们执行bgsave的时候,会开一个子进程去执行RDB。其实在内部是会去fork一个子进程,fork可以理解为克隆了一个子进程

6.fork的这个子进程,并不是针对物理内存的数据进行拷贝,仅仅是把页表的数据进行拷贝,也就是把映射关系拷贝给了子进程,这样子进程就有了和主进程相同的映射关系了,这样子进程在操作自己的虚拟内存的时候,因为和主进程的映射关系是一样的,所以最终一定能映射到相同的物理内存区域,这样就实现了子进程和主进程内存空间的共享

7.因为仅需要拷贝页表,速度是非常的快的,阻塞的时间就尽可能的缩短了

8.子进程拿到页表之后,就可以读到物理内存中的数据了,然后就可以把读到的数据写入到磁盘中,新的RDB文件会替换旧的RDB文件

9.还有一个问题就是,如果这个时候,主进程在修改数据,子进程在读和写,是不是有可能会有冲突,甚至是脏数据的产生?这个是如何避免的呢?

fork采用的是copy-on-write技术:

  • 当主进程执行读操作时,访问共享内存;

  • 当主进程执行写操作时,则会拷贝一份数据,执行写操作。

在fork的时候,会把共享的这个内存标记为read-only只读模式

当主进程要去写的时候,需要先拷贝一份副本,然后再去完成主进程的写操作,数据拷贝完成之后,读操作也会往这个副本去读了,同时页表的映射关系也会映射到这个副本上了,这样就避免了脏写的问题

2、AOF

AOF全称为Append Only File(追加文件)。Redis处理的每一个写命令都会记录在AOF文件,可以看做是命令日志文件。

AOF默认是关闭的,需要修改redis.conf配置文件来开启AOF

# 是否开启AOF功能,默认是no
appendonly yes
# AOF文件的名称
appendfilename "appendonly.aof"

AOF的命令记录的频率(也叫刷盘策略)也可以通过redis.conf文件来配置:

# 表示没执行一次写命令,立即记录到AOF文件
appendfsync always
# 写命令执行完先放入AOF缓冲区,然后表示每隔1秒将缓冲区数据写到AOF文件,是默认方案
appendfsync everysec
# 写命令执行完先放入AOF缓冲区,由操作系统决定何时将缓冲区内容写回磁盘
appendfsync no

一般都是使用everysec比较多

因为是记录命令,AOF文件会比RDB文件大的多。而且AOF会记录对同一个key的多次写操作,但只有最后一次写操作才有意义。通过执行bgrewriteaof命令,可以让AOF文件执行重写功能,用最少的命令达到相同效果。

Redis也会在触发阈值时自动去重写AOF文件。阈值也可以在redis.conf中配置:

# AOF文件比上次文件,增长超过多少百分比则触发重写
auto-aof-rewrite-percentage 100
# AOF文件体积最小多大以上才触发重写
auto-aof-rewrite-min-size 64mb

3、RDB与AOF对比

RDB和AOF各有自己的优缺点,如果对数据安全性要求较高,在实际开发中往往会结合两者来使用。

参考回答:

七、Redis的数据过期策略

假如redis的key过期之后,会立即删除吗?

Redis对数据设置数据的有效时间,数据过期以后,就需要将数据从内存中删除掉。可以按照不同的规则进行删除,这种删除规则就被称为数据的删除策略(数据过期策略)。

redis中提供了两种过期策略

  • 惰性删除

  • 定期删除

1、惰性删除

惰性删除:设置该key过期时间后,我们不去管它,当需要该key时,我们在检查其是否过期,如果过期,我们就删掉它,反之返回该key

优点:对CPU友好,只会在使用该key时才会进行过期检查,对于很多用不到的key不用浪费时间进行过期检查

缺点:对内存不友好,如果一个key已经过期,但是一直没有使用,那么该key就会一直存在内存中,内存永远不会释放

2、定期删除

定期删除:每隔一段时间,我们就对一些key进行检查,删除里面过期的key(从一定数量的数据库中取出一定数量的随机key进行检查,并删除其中的过期key)。

定期清理有两种模式:

  • SLOW模式时定时任务,执行频率默认为10hz,每次不超过25ms,以通过修改配置文件redis.conf的hz选项来调整这个次数

  • FAST模式执行频率不固定,但两次间隔不低于2ms,每次耗时不超过1ms

两种模式对时间的控制这么低都是为了尽量少的去占用主进程的操作

优点:可以通过限制删除操作执行的时长和频率来减少删除操作对CPU的影响。另外定期删除,也能有效释放过期键占用的内存。

缺点:难以确定删除操作执行的时长和频率。(执行太频繁对CPU不太友好,执行太少,那又和惰性删除一样,过期的数据得不到及时的删除)

Redis的过期删除策略:惰性删除 + 定期删除 两种策略进行配合使用

总结:

Redis的数据过期策略

  • 惰性删除:访问key的时候判断是否过期,如果过期,则删除

  • 定期删除:定期检查一定量的key是否过期(SLOW模式 + FAST模式)

  • Redis的过期删除策略:惰性删除 + 定期删除 两种策略进行配合使用

参考回答:

把、Redis的数据淘汰策略

假如缓存过多,内存是有限的,内存被占满了怎么办?

其实就是想问redis的数据淘汰策略是什么?

数据淘汰策略

数据淘汰策略:当redis中的内存不够用时,此时在向redis中添加新的key,那么redis就会按照某一种规则将内存中的数据删除掉,这种数据的删除规则被称之为内存的淘汰策略。

Redis支持8种不同策略来选择要删除的key:

  • noeviction:不淘汰任何key,但是内存满时不允许写放入新数据,默认就是这种策略

  • volatile-ttl:对设置了TTL的key,比较key的剩余TTL值,TTL越小越先被淘汰。

  • allkeys-random:对全体key,随机进行淘汰。

  • volatile-random:对设置了TTL的key,随机进行淘汰。

  • allkeys-lru:对全体key,基于LRU算法进行淘汰

  • volatile-lru:对设置了TTL的key,基于LRU算法进行淘汰

  • allkeys-lfu:对全体key,基于LFU算法进行淘汰

  • volatitle-lfu:对设置了TTL的key,基于LFU算法进行淘汰

数据淘汰策略-使用建议

1、优先使用allkeys-lru策略。充分利用LRU算法的优势,把最近最常访问的数据留在缓存中。如果业务有明显的冷热数据区分,建议使用。

2、如果业务中数据访问频率差别不大,没有明显冷热数据区分,建议使用allkeys-random,随机选择淘汰。

3、如果业务中有置顶的需求,可以使用volatitle-lru策略,同时置顶数据不设置过期时间,这些数据就一直不被删除,会淘汰其它设置过期时间的数据。

4、如果业务中有短时高频访问的数据,可以使用allkeys-lfu 或 volatitle-lfu 策略。

关于数据淘汰策略其它的面试问题

1、数据库有1000万数据,redis只能缓存20w数据,如何保证redis中的数据都是热点数据?

使用allkeys-lru(挑选最近最少使用的数据淘汰)淘汰策略,留下来的都是经常访问的热点数据

2、redis的内存用完了会发生什么?

主要看数据淘汰策略是什么?如果是默认的配置(noeviction),会直接报错

总结:

1、Redis提供了8种不同的数据淘汰策略,默认是noeviction不删除任何数据,内存不足直接报错

2、LRU:最少最近使用。用当前时间减去最后一次访问时间,这个值越大测淘汰优先级越高。

3、LFU:最少频率使用。会统计每个key的访问频率,值越小淘汰优先级越高。

平时开发过程中用的比较多的就是allkeys-lru(结合自己的业务场景)

参考回答:

九、redis分布式锁如何实现

redis分布式锁,是如何实现的?

需结合项目中的业务进行回答,通常情况下,分布式锁使用的场景:

集群情况下的定时任务、抢单、幂等性场景

抢券场景

/** 
 * 抢购优惠券
 * @throw InterruptedException
 */
public void rushToPurchase() throws InterruptedException {
	//获取优惠券数量
	Integer num = (Integer)redisTemplate.opsForValue().get("num");
	//判断是否抢完
	if(null == num || num <= 0) {
		throw new RuntimeException("优惠券已抢完");
	}
	//优惠券数据量减一,说明抢到了优惠券
	num = num -1;
	//重新设置优惠券的数量
	redisTemplate.opsForValue().set("num",num);
}

1、正常情况

如图,线程1进来先查询优惠券看库存是否充足,库存充足则扣除库存,库存不足则抛出异常。线程2也一样操作

2、不正常情况

假如线程是这样执行的,因为线程是交替执行的。刚好库存就剩下1个了

1.线程1进来先查询优惠券,还剩下1个。此时线程切换到了线程2执行,线程2查询优惠券也是剩下1个,然后扣减库存把值改为0,线程2执行完毕。然后切换回线程1执行,线程1因为先前查询到库存还剩1个,所以会继续扣减库存,此时库存就会被扣减为-1,就会造成超卖的现象

3、如果解决?

1、如果是单体项目,并且只提供了一台服务,可以加多线程中提供的synchronized锁

2、往往正常项目为了能解决更多的并发请求,会把服务进行集群部署

那这种场景用synchronized锁就不适合了,因为synchronized锁是本地的锁,是属于JVM层面的,而每个服务都有自己的JVM。

所以在集群的情况下就不能使用本地的锁了,需要使用到外部的锁,就是分布式锁

redis分布式锁

Redis实现分布式锁主要利用Redis的setnx命令。setnx是SET if not exists(如果不存在,则SET)的简写。

  • 获取锁

    • 设置值和过期时间放在一条命令执行是为了保证原子性,如果分开两条命令执行是无法保证原子性的

  • 释放锁

加锁流程

过期时间是为了防止服务超时或者服务宕机,其它线程一直无法持有锁,出现死锁的问题,所以过了到期时间自动释放锁

Redis实现分布式锁如何合理的控制锁的有效时长?

就是当我们加锁的时候给了一个失效时间,假如业务执行时间太久了,已经超过了锁的失效时间,那这个时候锁已经自动释放了,但是业务还没有执行完,如果线程有其它线程进来获取锁就可以获取成功,这种情况就无法保证业务执行的原子性,就可能影响业务数据

解决方案

1、根据业务执行时间预估(不好控制)

2、给锁续期

redisson实现的分布式锁-执行流程

代码实现

public void redisLock() throws InterruptedException {
	//获取锁(重入锁),执行所的名称
	RLock lock = redissonClient.getLock("guqinggeLock"); //根据业务设置唯一名称
	//尝试获取锁,参数分别是:获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位
	//boolean isLock = lock.tryLock(10,30,TimeUnit.SECONDS);
	boolean isLock = lock.tryLock(10,TimeUnit.SECONDS);
	//判断是否获取成功
	if (isLock) {
		try {
			System.out.println("执行业务");
		} finally {
			//释放锁
			lock.unlock();
		}
	}
}

加锁、设置过期时时间等操作都是基于lua脚本完成

redisson实现的分布式,重点有三个

1.watchDog可以给锁续期

2.抢不到锁的线程会进行尝试等待

3.所有的redis命令是基于lua脚本完成的,保证执行的原子性

redisson实现的分布式锁-可重入

public void add1() {
	Rlock lock = redissonClient.getLock("guqinggeLock");
	boolean isLock = lock.tryLock();
	//执行业务
	add2();
	//释放锁
	lock.unlock();
}

public void add2(){
	Rlock lock = redissonClient.getLock("guqinggeLock");
	//执行业务
	//释放锁
	lock.unlock();
}

利用hash结构记录线程id重入次数

当执行add1方法的时候,当前线程第一次获得了锁,在redis中存储的结构是这样的,KEY是锁的名称,field记录的是当前线程的唯一标识,value记录的是当前线程持有锁的次数,当前的次数应该是1。

当add1调用了add2方法的时候,又去获取了同一把锁,逻辑是先到缓存中去查询看看有没有当前线程获取的锁信息,如果数据可以查询到,就在value的基础上加1,这时候value重入次数就会变为2。

当add2方法业务执行完就会去释放锁,这时候释放锁并不会把这个锁删掉,而是在value的基础上减1,这时候value就变成了1,add2方法执行完成。

再回到add1方法,add1方法也会调用unlock方法去释放锁,释放锁成功之后,value重入次数就会变为0了,这时候就可以删除这个锁信息,这个效果就是锁重入了。

注意:redisson实现的分布式锁是可重入的,但要判断当前是不是同一个线程,如果是同一个线程的话才能重入,如果不是同一线程的话肯定是要互斥的。

redisson实现的分布式锁-主从一致性

如图,假如现在有java应用创建了一个分布式锁,因为是写数据,肯定需要先找到主节点,把数据写入到主节点中,正常情况下主节点需要把数据同步到从节点。但是假如还没来得及同步数据,主节点发生宕机了,依据redis提供的哨兵模式,会在从节点中选举出一个节点当做主节点。

当有新的线程来了以后,会直接请求新的主节点,也会去尝试获取锁,因为之前的数据没有同步过来,所以新的线程也能够加锁成功,这个时候就会出现两个线程同时持有同一把锁的情况,这就丧失了最基本的锁的特性了,没有互斥性了,如果业务还在执行,就可能会出现脏数据的现象。

主节点写数据,从节点读数据

那这种情况如何解决呢?

在redisson中提供了另外一个锁。

RedLock(红锁)

RedLock(红锁):不能只在一个redis实例上创建锁,应该是在多个redis实例上创建锁(n / 2 + 1),避免在一个redis实例上加锁。

但是项目中一般很少用到红锁,并且redis官方也不建议直接使用红锁来解决主从一致性的问题。

而且就是,像这种主从数据不一致出现的概率是极低的,很少有服务器天天宕机的。

还有就是redis整体的思想是AP思想,它优先保证的是高可用性,我们可以想办法做到最终一致性。如果有的业务一定要保证数据的强一致性的话,建议采用CP思想zookeeper去实现分布式锁,它能保证数据的强一致性。

总结:

redis分布式锁,是如何实现的?
  • 先按照自己简历上的业务进行描述分布式锁使用的场景

  • 我们当时使用的redisson实现的分布式锁,底层是setnxlua脚本(保证原子性)

redisson实现的分布式锁如何合理的控制锁的有效时长?

  • 在redisson的分布式锁中,提供了一个WatchDog(看门狗),一个线程获取锁成功以后,WatchDog会给持有锁的线程续期默认是每隔10秒续期一次

redisson的这个锁,可以重入吗?

  • 可以重入,多个锁重入需要判断是否是当前线程,在redis中进行存储的时候使用的hash结构,来存储线程信息和重入的次数

redisson锁能解决主从数据一致的问题吗?

  • 不能解决,但是可以使用redisson提供的红锁来解决,但是这样的话,性能就太低了,如果业务中非要保证数据的强一致性,建议采用zookeeper实现的分布式锁。

参考回答:

二、其它面试题

常问

  • 集群

    • 主从

    • 哨兵

    • 集群

  • 事务

  • redis为什么快

1、redis集群有哪些方案

在Redis中提供的集群方案总共有三种

  • 主从哨兵

  • 哨兵模式

  • 分片集群

里面也包含了一些其它的面试题

  • 1.redis主从数据同步的流程是什么?

  • 2.怎么保证redis的高并发可用?

  • 3.你们使用redis是单点还是集群,那种集群?

  • 4.redis分片集群中数据是怎么存储和读取的?

  • 5.redis集群脑裂,该怎么解决呢?

1、主从复制

单节点redis的并发能力时有上限的,要进一步提供redis的并发能力,就需要搭建主从集群,实现读写分离。

主从数据同步原理
主从全量同步

1、主节点是如何判断是否是第一次同步数据的?

  • 当从节点发起请求数据同步的时候,会把自己的replid发送给主节点master

  • 主节点会拿这个接收到的replid和自己的replid去比对,如果不一致,就证明这个从节点是第一次进行数据同步,然后就会把master自己的replid发送给从节点,从节点把这个信息记录到本地,这时候它们两个的replid就一致,这样才会执行下面的bgsave去同步数据。

2、注意:从节点在发起同步请求的时候,如果它们两个的replid是一样的,就证明之前是同步过数据,这个时候主节点就不会再去生成RDB文件了,而是通过repl_baklog这个日志文件去同步数据。那假如现在是第二次或者第三次同步数据,到底要从这个日志文件中读多少数据呢,是根据传递的offset偏移量来决定的,可以理解为一个自增的整数值。比如现在从节点的offset记录的是50,主节点记录的offset是80,主节点在给从节点同步数据的时候就会把50到80的这部分数据同步给从节点。

3、简单总结有三个大步骤:

  • 1.从节点发起数据同步请求给主节点master,主节点判断是否是第一次同步,如果是则全量同步

  • 2.主节点执行bgsave,生成RDB文件发送给从节点执行

  • 3.主节点在记录RDB期间接收的其它的命令会记录在一个repl_baklog日志文件中,再把这个日志文件发送给从节点执行

主从增量同步(slave重启或后期数据变化)

总结:
介绍一下redis的主从同步

单节点Redis的并发能力是有上限的,要进一步提高Redis的并发能力,就需要搭建主从集群,实现读写分离。一般都是一主多从,主节点负责写数据,从节点负责读数据

能说一下,主从同步数据的流程

全量同步

  • 1.从节点请求主节点同步数据 (replication id、 offset)

  • 2.主节点判断是否是第一次请求,是第一次就与从节点同步版本信息 (replication id和offset)

  • 3.主节点执行bgsave,生成rdb文件后,发送给从节点去执行

  • 4.在rdb生成执行期间,主节点会以命令的方式记录到缓冲区(一个日志文件)

  • 5.把生成之后的命令日志文件发送给从节点进行同步

增量同步

  • 1.从节点请求主节点同步数据,主节点判断不是第一次请求,不是第一次就获取从节点的offset值

  • 2.主节点从命令日志中获取offset值之后的数据,发送给从节点进行数据同步

参考回答:

2、哨兵模式

Redis提供了哨兵(Sentinel)机制来实现主从集群的自动故障恢复。哨兵的结构和作用如下:

  • 监控:Sentinel会不断检查您的master和slave是否按期工作

  • 自动故障恢复:如果master故障,Sentinel会将一个slave提升为master。故障实例恢复后也以新的master为主

  • 通知:Sentinel充当Redis客户端的服务发现来源,当集群发生故障转移时,会将最新信息推送给Redis的客户端

服务状态监控

Sentinel基于心跳机制监测服务状态,每隔1秒向集群的每个实例发送ping命令:

  • 主观下线:如果某sentinel节点发现某实例未在规定时间响应,则认为该实例主观下线

  • 客观下线:若超过指定数量(quorum)【可以通过rdis.conf文件配置】的sentinel都认为该实例主观下线,则该实例客观下线。quorum值最好超过Sentinel实例数量的一半。

哨兵选主规则

  • 首先判断主与从节点断开时间长短,如超过指定值就排该从节点

  • 然后判断从节点的slave-priority值,越小优先级越高

  • 如果slave-prority一样,则判断slave节点的offset值,越大优先级越高

  • 最后是判断slave节点的运行id大小,越小优先级越高。

redis集群(哨兵模式)脑裂

什么是脑裂现象

正常的主从架构,配合了哨兵模式

假如因为网络原因,主节点master和哨兵都处于不同的网络分区,哨兵只能去监测从节点,监测不到主节点了。这时候哨兵就会因为选主的规则从 从节点中选出一个节点当做主节点。

但是旧的主节点还没有挂掉,只是网络出现了问题,客户端还能正常的连接。那现在就出现了两个master,就像大脑分裂了一样,这个就是脑裂的现象。

会出现什么问题?

目前的客户端连接的是老的master,它会持续往老的master中写入数据,那新的节点就不能同步数据了,因为现在网络还有问题。

假如现在网络恢复了,哨兵会就老的master,强制降为slave,这时候这个slave就会从master中同步数据,会先把自己的数据给清空,但是在之前的脑裂过程中,客户端写入的数据就丢失了。这个就是脑裂现象出现之后,导致数据丢失的问题了。

如何解决?

redis中有两个配置参数:

  • min-replicas-to-write 1 表示最少的slave节点为1个

    • 比如说,当客户端连接主节点写入数据的时候,这个主节点必须要有至少一个从节点,才能接收客户端的数据,否则直接拒绝请求。

  • min-replicas-max-lag 5 表示数据复制和同步的延迟不能超过5秒

3、总结:

怎么保证Redis的共并发高可用

哨兵模式:实现主从集群的自动故障恢复(监控、自动故障恢复、通知)

你们使用redis是单点还是集群,哪种集群

主从(1主1从)+哨兵就可以了。单节点不超过10G内存,如果Redis内存不足则可以给不同服务分配独立的Redis主从节点

redis集群脑裂,该怎么解决呢?

集群脑裂是由于主节点和从节点和sentinel处于不同的网络分区,使得sentinel没有能够心跳感知到主节点,所以通过选举的方式提升了一个从节点为主,这样就存在了两个master,就像大脑分裂了一样,这样会导致客户端还在老的主节点那里写入数据,新节点无法同步数据,当网络恢复后,sentinel会将老的主节点降为从节点,这时再从新master同步数据,就会导致数据丢失

解决:我们可以修改redis的配置,可以设置最少的从节点数量以及缩短主从数据同步的延迟时间,达不到要求就拒绝请求就可以避免大量的数据丢失

4、参考回答:

2、分片集群结构

主从和哨兵可以解决高可用、高并发读的问题。但是依然有两个问题没有解决:

  • 海量数据存储问题

  • 高并发写的问题

使用分片集群可以解决上述问题,分片集群特征:

  • 集群中有多个master,每个master保存不同数据

  • 每个master都可以有多个slave节点

  • master之间通过ping监测彼此健康状态

  • 客户端请求可以访问集群任意节点,最终都会被转发到正确节点

分片集群结构-数据读写

Redis 分片集群引入了哈希槽的概念,Redis 集群有16384 个哈希槽,每个 key通过 CRC16 校验后对16384 取模来决定放置哪个槽,集群的每个节点负责一部分 hash槽。

我们也可以按照一点的规则来决定key存储到哪个节点中

如果说有相同类型的业务数据,都想存储到同一个redis的节点下,就可以设置相同的有效部分来存储数据(如括号中的aaa)。

如果没有设置有效部分,那key就是有效部分,就会用key来计算hash值

总结:

redis的分片集群有什么作用
  • 集群中有多个master,每个master保存不同数据

  • 每个master都可以有多个slave节点

  • master之间通过ping监测彼此健康状态

  • 客户端请求可以访问集群任意节点,最终都会被转发到正确节点

Redis分片集群中数据是怎么存储和读取的?
  • Redis 分片集群引入了哈希槽的概念,Redis 集群有16384 个哈希槽

  • 将16384个插槽分配到不同的实例

  • 读写数据:根据key的有效部分计算哈希值,对16384取余(有效部分,如果key前面有大括号,大括号的内容就是有效部分,如果没有,则以key本身做为有效部分)余数做为插槽,寻找插槽所在的实例

参考回答:

3、Redis是单线程的,但是为什么还那么快

  • Redis是纯内存操作,执行速度非常快

  • 采用单线程,避免不必要的上下文切换可竞争条件,多线程还要考虑线程安全问题

  • 使用I/O多路复用模型,非阻塞IO

能解释一下I/O多路复用模型?

Redis是纯内存操作,执行速度非常快,它的性能瓶颈是网络延迟而不是执行速度,I/O多路复用模型主要就是实现了高效的网络请求

  • 用户空间和内核空间

  • 常见的IO模型

    • 阻塞lO (Blocking IO)

    • 非阻塞IO (Nonblocking IO)

    • IO多路复用 (IO Multiplexing)

  • Redis网络模型

用户空间和内核空间
  • Linux系统中一个进程使用的内存情况划分两部分:内核空间、用户空间

  • 用户空间只能执行受限的命令(Ring3),而且不能直接调用系统资源必须通过内核提供的接口来访问

  • 内核空间可以执行特权命令(Ring0),调用一切系统资源

Linux系统为了提高lO效率,会在用户空间和内核空间都加入缓冲区:

  • 写数据时,要把用户缓冲数据拷贝到内核缓冲区,然后写入设备

  • 读数据时,要从设备读取数据到内核缓冲区,然后拷贝到用户缓冲区

阻塞IO

顾名思义,阻塞IO就是两个阶段都必须阻塞等待:

阶段一:

  • ①用户进程尝试读取数据(比如网卡数据)

  • ②此时数据尚未到达,内核需要等待数据

  • ③此时用户进程也处于阻塞状态

阶段二:

  • ①数据到达并拷贝到内核缓冲区,代表已就绪

  • ②将内核数据拷贝到用户缓冲区

  • ③拷贝过程中,用户进程依然阻塞等待

  • ④拷贝完成,用户进程解除阻塞,处理数据

可以看到,阻塞IO模型中,用户进程在两个阶段都是阻塞状态。

非阻塞IO

顾名思义,非阻塞IO的recvfrom操作会立即返回结果而不是阻塞用户进程。

阶段一:

  • ①用户进程尝试读取数据(比如网卡数据)

  • ②此时数据尚未到达,内核需要等待数据

  • ③返回异常给用户进程

  • ④用户进程拿到error后,再次尝试读取

  • ⑤循环往复,直到数据就绪

阶段二:

  • ①将内核数据拷贝到用户缓冲区

  • ②拷贝过程中,用户进程依然阻塞等待

  • ③拷贝完成,用户进程解除阻塞,处理数据

可以看到,非阻塞lO模型中,用户进程在第一个阶段是非阻塞,第二个阶段是阻塞状态。虽然是非阻塞,但性能并没有得到提高。而且忙等机制会导致CPU空转,CPU使用率暴增。

IO多路复用

IO多路复用:是利用单个线程来同时监听多个Socket,并在某个Socket可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。

阶段一:

  • ①用户进程调用select,指定要监听的Socket集合

  • ②内核监听对应的多个socket

  • ③任意一个或多个socket数据就绪则返回readable

  • ④此过程中用户进程阻塞

阶段二:

  • ①用户进程找到就绪的socket

  • ②依次调用recvfrom读取数据

  • ③内核将数据拷贝到用户空间

  • ④用户进程处理数据

实现方式

IO多路复用是利用单个线程来同时监听多个Socket,并在某个Socket可读、可写时得到通知,从而避免无效的等待充分利用CPU资源。不过监听Socket的方式、通知的方式又有多种实现,常见的有:

  • select

  • poll

  • epoll

点餐案例:

如下图,左边是服务员,右边是顾客。现在需要点餐,点餐的流程大概是这样的,这里会有两种效果

1.select和poll(监听的模式)

  • 每个顾客都坐在桌子面前,每个桌子上都安了一个开关,这个开关会连接到服务员的灯泡上,任意一名顾客按下开关就会通知到服务员,服务员面前的灯就会亮了

  • 那这样服务员就知道有顾客就绪了,需要点餐了。但是这里有一个问题,因为服务员面前就一个灯,任意一名顾客按下开关都会亮,服务员就不知道到底是哪一名顾客就绪了。

  • 服务员就会去一个一个的询问,直到找到了真正要点餐的顾客为止。

  • 这种模式就是早期的IO多路复用的方案,也就是select和poll的实现方案

2.epoll

  • 上面这种方案可以看到它的弊端,这种通知的机制不太好,不知道是谁就绪了,需要一个一个去遍历询问

  • 后来的linux系统中就升级了一种新的通知方案,叫做epoll

  • 这个的机制是,所有顾客面前的按钮不再控制服务员面前的灯泡了,而是控制服务员面前的计算机,顾客按下按钮之后,就会在计算机上显示出是几号桌就绪的,这样服务员就能快速找到就绪的顾客然后去点餐

差异:

  • select和poll只会通知用户进程有Socket就绪,但不确定具体是哪个Socket,需要用户进程逐个遍历Socket来确认

  • epoll则会在通知用户进程Socket就绪的同时,把已就绪的Socket写入用户空间

Redis网络模型

Redis通过IO多路复用来提高网络性能,并且支持各种不同的多路复用实现,并且将这些实现进行封装,提供了统一的高性能事件库

总结:

能解释一下I/O多路复用模型?

1.I/O多路复用

是指利用单个线程来同时监听多个Socket,并在某个Socket可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。目前的I/O多路复用都是采用的epoll模式实现,它会在通知用户进程Socket就绪的同时,把己就绪的Socket写入用户空间,不需要挨个遍历Socket来判断是否就绪,提升了性能。

2.Redis网络模型

就是使用I/O多路复用结合事件的处理器来应对多个Socket请求

  • 连接应答处理器

  • 命令回复处理器,在Redis6.0之后,为了提升更好的性能,使用了多线程来处理回复事件

  • 命令请求处理器,在Redis6.0之后,将命令的转换使用了多线程,增加命令转换速度,在命令执行的时候,依然是单线程

参考回答:


二、MYSQL篇

一、优化

1、定位慢查询

在MySQL中,如何定位慢查询?

  • 聚合查询

  • 多表查询

  • 表数据量过大查询

  • 深度分页查询

表象:页面加载过慢、接口压测响应时间过长(超过1s)

方案一:开源工具

  • 调试工具:Arthas

  • 运维工具:Prometheus、Skywalking

方案二:MySQL自带慢日志

慢查询日志记录了所有执行时间超过指定参数(long_query_time,单位:秒,默认10秒)的所有SQL语句的日志

如果要开启慢查询日志,需要在MySQL的配置文件 (/etc/my.cnf) 中配置如下信息:

# 开启MySQL慢日志查询开关
slow_query_log=1
# 设置慢日志的时间为2秒,SQL语句执行时间超过2秒,就会视为慢查询,记录慢查询日志
long_query_time=2

配置完毕之后,通过以下指令重新启动MySQL服务器进行测试,查看慢日志文件中记录的信息

/var/lib/mysql/localhost-slow.log

总结:

如何定位慢查询?
  1. 介绍一下当时产生问题的场景(我们当时的一个接口测试的时候非常的慢,压测的结果大概5秒钟)

  2. 我们系统中当时采用了运维工具(Skywalking),可以监测出哪个接口,最终因为是sql的问题

  3. 在mysql中开启了慢日志查询,我们设置的值就是2秒,一旦sql执行超过2秒就会记录到日志中(调试阶段,一般在生产环境是不会开启的,会损耗一些mysql的性能)

参考回答:

2、SQL执行计划

那这个SQL执行很慢,如何分析呢?

  • 聚合查询(可以尝试新增临时表解决)

  • 多表查询(可以尝试优化sql语句的结构)

  • 表数据量过大查询(添加索引)

  • 深度分页查询

可以采用EXPLAIN 或者 DESC命令获取 MySQL 如何执行 SELECT 语句的信息

语法:

-- 直接在select语句之前加关键字 explain / desc
EXPLAIN SELECT 字段列表 FROM 表名 WHERE 条件;

  • possible_key:当前sql可能会使用到的索引

  • key:当前sql实际命中的索引

  • key_len:索引占用的大小

  • Extra:额外的优化建议

Extra

含义

Using where; Using Index

查找使用了索引,需要的数据都在索引列中能找到,不需要回表查询数据

Using index condition

查找使用了索引,但是需要回表查询数据

  • type:这条sql的连接的类型,性能由好到差为

    • NULL

      • 代表的是这条sql语句执行的时候没有使用到表,这个在实际情况中是很少见的,无需关注

    • system

      • 查询系统中的表(mysql中内置的表,性能较好),实际开发中也很少,用的不多

    • const

      • 根据主键查询

    • eq_ref

      • 主键索引查询或唯一索引查询(只能返回一条数据)

    • ref

      • 索引查询(返回的数据很可能是多条数据,比如根据地域查询,返回的是多条数据)

    • range

      • 范围查询(走的是索引,但是是范围查询)

    • index

      • 索引树扫描(走的全索引查询,会遍历整个索引树去检索结果,效率不高)

    • all

      • 全盘扫描(不走索引)

如果某一条SQL的连接类型是index或者是all,那这条sql就需要优化了

总结:

那这个SQL语句执行很慢,如何分析呢?

可以采用MySQL自带的分析工具 EXPLAIN

  • 通过key和key_len检查是否命中了索引(索引本身存在是否有失效的情况)

  • 通过type字段查看sql是否有进一步的优化空间,是否存在全索引扫描或全盘扫描

  • 通过extra建议判断,是否出现了回表的情况,如果出现了,可以尝试添加索引或修改返回字段来修复

参考回答:

3、索引

了解过索引吗?(什么是索引)

索引(index)是帮助MySQL高效获取数据的数据结构(有序)。在数据之外,数据库系统还维护着满足特定查找算法的数据结构(B+树),这些数据结构以某种方式引用(指向)数据,这样就可以在这些数据结构上实现高级查找算法,这种数据结构就是索引。

索引的底层数据结构了解过嘛?

B+树

数据结构对比

MySQL默认使用的索引底层数据结构是B+树。再聊B+树之前,我们先聊聊二叉树和B树

二叉搜索树

如上图,如果这个二叉搜索树是相对平衡的话,查找的时间复杂度为O(log n),是对数级的

最坏的二叉树

这个的二叉树退化成了一个链表,它的查找的时间复杂度就相对比较查了,为O(n)

所以,由于当前的二叉树的时间复杂度不太稳定,MySQL底层并没使用二叉树

红黑树

红黑树的特点是,节点是可以保证平衡的,所以时间复杂度相对就比较稳定,时间复杂度是O(log n)

但是,由于红黑树也是一个二叉树(相对平衡二叉树),每个节点只能有两个分支,即只有两个子节点。如果数据量特别大,比如有1000w的数据,都存储到红黑树中,那就会使这个红黑树变的特别的高(深度大),如果需要查找数据,依然需要查找很多的层级才能找到想要的数据。

B-Tree

B-Tree,B树是一种多叉路衡查找树,相对于二叉树,B树每个节点可以有多个分支,即多叉。

以一颗最大度数(max-degree)为5(5阶)的b-tree为例,那这个B树每个节点最多存储4个key

如上图,B树每个节点最多可以存储4个key,第一个节点上的20,30,62,89就是对应的key。灰色的部分为指针,指向子节点的数据。

B-tree和上面二叉树的特点也是一样的,也是左边是小的右边是大的。比如key为20的,指针指向的是下面左边第一个子节点,对应的都是20以内的数据,分别是10,15,18。 再看20到30中间的这个指针,指向的是下面第二个子节点,对应的23,25,28,都是20到30之间的数据。

同时,每个key也都存储了对应的数据,比如20这个key,下面的绿色部分,就是对应的数据。

相对于二叉树,B树是一个矮框树,由于分支比较多,所以层级比较短,查找效率自然就比较高了。

但是MySQL用的是B+树,因为B+树要比B树更加优秀。

B+Tree

B+ Tree是在BTree基础上的一种优化,使其更适合实现外存储索引结构,InnoDB存储引|擎就是用B+Tree实现其索引结构

如图,与B树不同的是,B+Tree的非叶子节点,只存储指针,不存储数据,只有在叶子节点才会真正的存储数据。

上面的都是非叶子节点,都是为了导航找到叶子节点来获取数据的。看非叶子节点上的38,在下面的叶子节点中也能找到38对应的数据。同理像非叶子节点上的58,在下面叶子节点也能状况58对应的数据。

那它相对于B-Tree有什么特点呢?

B树与B+树对比:

  • ①磁盘读写代价B+树更低

    • 非叶子节点只存储指针不存储数据,存储压力相对更低

    • 比如我们现在要查询12对应的数据,如果是B树,它会先找到跟节点,找到38,然后会把38对应的数据查询出来。因为12要比38要小,所以会往左边找,找到16和29这两个键值,它会把16和29这两个键值对应的数据也会查询出来,最终才会去定位到12键值对应的数据。其中,在路径上的这些节点,因为B树是包含数据的,所以会把38,16,29这些键值对应的数据都会加载出来,但是我们只需要12键值的数据,它就额外加载了其它的数据。

    • 但是B+树就不一样了,它只有在叶子节点上才会存储数据,上面的非叶子节点存储的是指针,这些指针只是为了方便找到叶子节点的数据,相当于一个导航,所以效率要高很多,磁盘读写要更低一些。

  • ②查询效率B+树更加稳定

    • 因为B+树所有的数据都存储到叶子节点上,在查找数据的时候,都要从根节点上往下一个一个去对比,最终都要到叶子节点上获取数据,它们的查找路径是差不多的,所以效率是比较稳定的。

  • ③B+树便于扫库和区间查询

    • 叶子节点之间是使用双向指针进行连接的,在进行范围查询的时候更加方便。比如现在要查询6-34之间的数据,首先会先到根节点去对比一次,然后从左边找到16,再从左边找到6.由于节点之间有双向指针(双向链表),所以它一次性可以把所有数据都给拿到,像16,18,29,34对应的数据,都不需要再从根节点去从上往下再找一次。

总结:

了解过索引吗?(什么是索引)
  • 索引(index)是帮助MySQL高效获取数据的数据结构(有序)

  • 提高数据检索的效率,降低数据库的IO成本(不需要全表扫描)

  • 通过索引列对数据进行排序,降低数据排序的成本,降低了CPU的消耗

索引的底层数据结构了解过嘛?

MySQL的InnoDB引擎采用的B+树的数据结构来存储索引

  • 阶数更多,路径更短

  • 磁盘谈写代价B+树更低,非叶子节点只存储指针,叶子阶段存储数据

  • B+树便于扫库和区间查询,叶子节点是一个双向链表

参考回答:

4、SQL优化经验

1、什么是聚簇索引什么是非聚簇索引?

什么是聚集索引,什么是二级索引(非聚集索引)【聚集索引也叫聚簇索引】

什么是回表?

聚集索引选取规则

  • 如果存在主键,主键索引就是聚集索引。

  • 如果不存在主键,将使用第一个唯一(UNIQUE)索引作为聚集索引。

  • 如果表没有主键,或没有合适的唯一索引,则InnoDB会自动生成一个rowid作为隐藏的聚集索引

聚集索引:

如图,索引树上的key代表的都是表中的主键,叶子节点中对应的都是整行的数据,每个id对应的都是一行记录。这个就是聚集索引,也叫做聚簇索引

如图,我们给name这个字段添加了索引,索引树的数据结构如上图下方这样的,像索引树中的Lee,Rose都是name字段所对应的值,因为也是B+树的结构,它们的值真正存储也是在叶子节点,但是,在叶子节点上存储的并不像聚集索引那样存储一行数据,而是这行数据所对应的主键值。这个就是二级索引,也叫非聚簇索引。

回表查询

如图,这条查询语句,因为我们刚才给name字段添加了索引,所以它会走二级索引,先会拿到Arm到根节点去比对,A在L的左边,会往左边找到Geek再比对,然后A在G的左边,就找到了Arm。

但是这条sql语句是 select * ,当前的二级索引中不能拿到所有的数据,它只能拿到Arm对应的10。然后它会拿到这个10,再到聚集索引中去找,先到聚集索引的根节点比对,10跟15比对要小,从左边找找到10,就能定位到当前的数据了,这个id是10里面,是保存了整行的数据的。

整个过程简单来说就是:先通过二级索引找到对应的主键值,拿到主键再到聚集索引中找到主键对应的整行的数据,这个过程就是回表查询。

总结:
什么是聚簇索引什么是非聚簇索引?
  • 聚簇索引(棸集索引):数据与索引放到一块,B+树的叶子节点保存了整行数据有且只有一个

  • 非聚簇索引(二级索引):数据与索引分开存储,B+树的叶子节点保存对应的主键,可以有多个(我们单独给字段创建的索引大多都是二级索引)

知道什么是回表查询嘛?

通过二级索引找到对应的主键值,到聚集索引中查找整行数据,这个过程就是回表

参考回答:

2、知道什么叫覆盖索引嘛?

覆盖索引是指查询使用了索引,并且需要返回的列,在该索引中已经全部能够找到。

  • select * from tb_user where id = 1;

    • 这个是覆盖索引,虽然使用了select * ,但是id默认是主键索引,即聚集索引,聚集索引的叶子节点存储的是一整行的数据,所以依然可以从索引中查询到所有的数据。

  • select id, name from tb_user where name = 'Arm';

    • 这个也是覆盖索引,因为name是二级索引,需要查询的是id和name,'Arm'本身就会添加的索引中,在二级索引的叶子节点存储的是id值,所以id和name一次性都可以查询出来。

  • select id, name ,gender from tb_user where name 'Arm';

    • 这个不是覆盖索引,需要回表。因为通过name = 'Arm' 这个查询条件查询的时候,走的是二级索引,二级索引树中找到键值为 'Arm' 的叶子节点存储的id值,但是还有一个gender字段在二级索引树中查询不到,需要再拿id到聚集索引树中找到id键值对应的整行数据拿到这个gender字段的值

总结:
知道什么叫覆盖索引嘛?

覆盖索引是指查询使用了索引,返回的列,必须在索引中全部能够找到

  • 使用id查询,直接走聚集索引查询,一次索引扫描,直接返回数据,性能高。

  • 如果返回的列中没有创建索引,有可能会触发回表查询,尽量避免使用select *

MYSQL超大分页怎么处理?

可以使用覆盖索引解决

在数据量比较大时,如果进行limit分页查询,在查询时,越往后,分页查询效率越低。

我们一起来看看执行limit分页查询耗时对比:

因为,当在进行分页查询时,如果执行 limit 9000000,10,此时需要MySQL排序前9000010 记录,仅仅返回9000000-9000010 的记录,其他记录丢弃,查询排序的代价非常大。

优化思路:一般分页查询时,通过创建 覆盖索引能够比较好地提高性能,可以通过覆盖索引子查询形式进行优化

参考回答:

3、索引创建的原则有哪些?

  • 先陈述自己在实际工作中成怎么用的

  • 主键索引

  • 唯一索引

  • 根据业务创建的索引(一般是复合索引)

(1)针对于数据量较大,且查询比较频繁的表建立索引。

  • 单表超过10w数据(增加用户体验)

(2)针对于常作为查询条件 (where)、排序 (order by)、分组(group by)操作的字段建立索引。

(3)尽量选择区分度高的列作为素引,尽量建立唯一索引,区分度越高,使用索引的效率越高。

  • 比如像图中地址这个字段,一个地址里面可能有多个公司,如果说按照城市查找数据,城市这个字段区分度就不高,因为这里面有大量的北京市,这样我们使用索引的效率也就不高,这种情况的话也不是不符合,而是尽量不使用。

(4)如果是字符串类型的字段,字段的长度较长,可以针对于字段的特点,建立前缀索引。

(5)尽量使用联合索引,减少单列索引,查询时,联合索引很多时候可以覆盖索引,节省存储空间,避免回表,提高查询效率。

(6)要控制索引的数量,索引并不是多多益善,索引)越多,维护索引结构的代价也就越大,会影响增删改的效率

(7)如果索引列不能存储NULL值,请在创建表时使用NOT NULL约束它。当优化器知道每列是否包含NULL值时,它可以更好地确定哪个索引最有效地用于查询。

总结:
索引创建原则有哪些?
  1. 数据量较大,且查询比较频繁的表(重要)

  2. 常作为查询条件、排序、分组的字段(重要)

  3. 字段内容区分度高

  4. 内容较长,使用前缀索引

  5. 尽量联合索引(重要)

  6. 要控制索引的数量(重要)

  7. 如果索引列不能存储NULL值,请在创建表时使用NOT NULL约束它

参考回答:

4、什么情况下索引会失效?

索引失效的情况有很多,可以说一些自己遇到过的,不要张口就得说一堆背诵好的面试题(适当的思考一下,回想一下,更真实)

给tb_seller创建联合索引,字段顺序:name, status, address

能不能判断索引是否失效了呢? --执行计划explain

什么情况下索引会失效?
1)违反了最左前缀法则

如果索引了多列,要遵守最左前缀法则。指的是查询从索引的最左前列开始,并且不跳过索引中的列。匹配最左前缀法则,走索引:

正例:

  • 如图第一个sql,虽然只用到了一个name字段,但是还是命中了索引

  • 第二个sql,用到的条件是name和status,同样也命中了索引

  • 第三个sql,用到的条件是name、status和address,一样命中了索引

  • 也就是说,当前这个索引,从name开始查没问题,name和status查也没问题,name、status、address这样查也没问题。

反例:

违反最左前缀法则,索引失效:

  • 如图第一个sql,跳过了name,用的是status和address查询,可以看到是没有命中索引的,索引已经失效了。

  • 第二条sql,是只查询了状态,或者只查询了address,跳过了name,也是没有命中索引的,索引同样失效。

如果符合最左法则,但是出现跳跃某一列,只有最左列索引生效:

  • 如图,查询条件跳过了status,看到是命中了索引,但是长度是303。

  • 可以看到上面,只命中一个name的时候,长度就是303,命中两个是309,三个是612,也就是说,当前的这条sql,只是命中了name这一个索引,address是没有命中索引的。

2)范围查询右边的列,不能使用索引。

  • 如图第一条sql,是一个正例,都命中了索引,长度612,是没有问题的。

  • 第二条sql,用的是name,status,address,顺序是没有问题的,但是status用的是范围查询 > , 看到key_len长度是309,很显然,name和status是可以命中索引的,后面的address索引是已经失效了,所以说范围查询右边的列是不能使用索引的。

根据前面的两个字段 name、status 查询是走索引的,但是最后一个条件address 没有用到索引。

3)不要在索引列上进行运算操作,索引将失效

4)字符串不加单引号,造成索引失效。

由于,在查询时,没有对字符串加单引号,MySQL的查询优化器,会自动的进行类型转换,造成索引失效。

5)以%开头的Like模糊查询,索引失效。如果仅仅是尾部模糊匹配,索引不会失效。如果是头部模糊匹配,索引失效。

只要是%在前边的,都会导致索引失效。

总结:
什么情况下索引会失效?
  1. 违反最左前缀法则

  2. 范围查询右边的列,不能使用索引

  3. 不要在索引列上进行运算操作,索引将失效

  4. 字符串不加单引号,造成索引失效。(类型转换)

  5. 以%开头的Like模糊查询,索引失效

参考回答:

5、谈一谈你对sql的优化的经验

  • 表的设计优化

  • 索引优化 (参考优化创建原则和索引失效)

  • SQL语句优化

  • 主从复制、读写分离

  • 分库分表 (下面介绍)

表的设计优化(参考阿里开发手册《嵩山版》)

  • ①比如设置合适的数值(tinyint int bigint),要根据实际情况选择

  • ②比如设置合适的字符串类型(char和varchar)char定长效率高,varchar可变长度,效率稍低

SQL语句优化

  • ①SELECT语句务必指明字段名称(避免直接使用select*

  • ②SQL语句要避免造成索引失效的写法

  • ③尽量用union all代替union , union会多一次过滤,效率低

    • union all会将两条sql查询结果集拼一起,有重复数据不会过滤掉,会一起展示出来,而union会多一次过滤的操作,重复的数据会过滤掉只展示一个

  • ④避免在where子句中对字段进行表达式操作

  • ⑤Join优化 能用innerjoin 就不用left join right join,如必须使用 一定要以小表为驱动,内连接会对两个表进行优化,优先把小表放到外边,把大表放到里边。left join 或 right join,不会重新调整顺序

主从复制、读写分离

如果数据库的使用场景读的操作比较多的时候,为了避免写的操作所造成的性能影响 可以采用读写分离的架构。

读写分离解决的是,数据库的写入,影响了查询的效率。

总结:

谈一谈你对sql的优化的经验
  1. 表的设计优化,数据类型的选择

  2. 索引优化,索引创建原则

  3. sq语句优化,避免索引失效,避免使用select *

  4. 主从复制、读写分离,不让数据的写入,影响读操作

  5. 分库分表

参考回答:

二、其它面试题

1、事务相关

事务的特性是什么?可以详细说一下吗?ACID

事务是一组操作的集合,它是一个不可分割的工作单位,事务会把所有的操作作为一个整体一起向系统提交或撤销操作请求,即这些操作要么同时成功,要么同时失败。

  • 原子性(Atomicity)

  • 一致性( Consistency

  • 隔离性( Isolation)

  • 持久性( Durability

ACID是什么?可以详细说一下吗?
  • 原子性(Atomicity):事务是不可分割的最小操作单元,要么全部成功,要么全部失败。

  • 一致性(Consistency):事务完成时,必须使所有的数据都保持一致状态。

  • 隔离性(Isolation):数据库系统提供的隔离机制,保证事务在不受外部并发操作影响的独立环境下运行。

  • 持久性(Durability):事务一旦提交或回滚,它对数据库中的数据的改变就是永久的。

参考回答:

并发事务带来哪些问题?怎么解决这些问题?MySQL的隔离级别是?

  • 并发事务问题:脏读、不可重复读、幻读

  • 隔离级别:读未提交、读已提交、可重复读、串行化

并发事务问题

  • 脏读:

    • 事务A修改了数据,但是还没提交,但是事务B读到了事务A还没提交的这个数据,这个就是脏读

  • 不可重复读

    • 事务A查询id为1的数据,这时候事务B修改了id为1的数据并且已经提交了。

    • 再回到事务A,事务A再查询id为1的数据,读到的是事务B修改提交之后的数据,事务A在同一个事务内前后两次读到的数据是不一致的,这就是不可重复读的问题。

  • 幻读

    • 事务A先去查询了数据库id为1的数据,这时候读到的结果是没有值。但是当事务A还没有插入的时候,来了一个事务B,事务B往数据库中插入了一条数据正好是id为1的,并且提交了事务。这时候事务A往数据库中插入数据的时候就直接报错了,报id为1的数据已存在。然后事务A再去查询一次,但是依然是查询不到的(前提是解决了不可重复读的问题的,在同一个事务内,不管查询多少次,读到的数据都是一样的),这就是幻读

怎么解决并发事务的问题呢?

解决方案:对事务进行隔离

注意:事务隔离级别越高,数据越安全,但是性能越低。

总结:
并发事务带来哪些问题?怎么解决这些问题呢?MySQL的默认隔离级别是?
  • 并发事务的问题

    • ①脏读:一个事务读到另外一个事务还没有提交的数据。

    • ②不可重复读:一个事务先后读取同一条记录,但两次读取的数据不同

    • ③幻读:一个事务按照条件查询数据时,没有对应的数据行,但是在插入数据时,又发现这行数据已经存在,好像出现了”幻影”。

  • 隔离级别

    • ① READ UNCOMMITTED 未提交读

      • 不能解决 脏读、不可重复读、幻读 问题

    • ② READ COMMITTED 读已提交

      • 不能解决 不可重复读、幻读 问题

    • ③ REPEATABLE READ 可重复读(默认)

      • 不能解决 幻读 问题

    • ④ SERIALIZABLE 串行化

      • 可以解决上面所有问题,但是性能差

参考回答:

undo log 和 redo log的区别

  • 缓冲池 (buffer pool):主内存中的一个区域,里面可以缓存磁盘上经常操作的真实数据,在执行增删改查操作时,先操作缓冲池中的数据(若缓冲池没有数据,则从磁盘加载并缓存),以一定频率刷新到磁盘,从而减少磁盘IO,加快处理速度

  • 数据页 (page):是InnoDB 存储引/擎磁盘管理的最小单元,每个页的大小默认为 16KB。页中存储的是行数据

我们操作数据的时候,比如update、delete等操作,并不会直接去操作磁盘,首先会去操作内存(缓冲池)。当一个操作进来之后,首先会去Buffer Pool查询有没有需要操作的数据,如果没有的话,会到磁盘中把数据加载到内存中,这时候就会把某一页的数据存储到缓冲池中,操作完成之后,会按照一定的频率再把数据同步到磁盘中,这样就能减少磁盘IO,加快处理速度。

但是有可能会出现这么一个问题,就是当前已经操作完成的数据,在内存的缓冲池中存储的这个页,还没有同步到磁盘中,这个页被称为脏页,这个脏页需要被同步到磁盘中才算是真正的持久化。但是如果这时候服务器宕机了,同步数据失败了,同步失败之后,内存中的数据有可能会消失,这样数据可能就丢失了,操作完成的数据也就丢失了,这就违背了事务的特性持久化

所以在mysql中引入了一种日志文件,redo log

redo log

重做日志,记录的是事务提交时数据页的物理修改,是用来实现事务的持久性

该日志文件由两部分组成:重做日志缓冲(redo log buffer) 以及重做日志文件(redo log file),前者是在内存中,后者在磁盘中。当事务提交之后会把所有修改信息都存到该日志文件中,用于在刷新脏页到磁盘,发生错误时,进行数据恢复使用。

当有增删改操作的时候,Buffer Pool发生了变化,这时候,Redolog buffer中就会记录这些数据页的变化,一旦Redolog buffer发生了变化,就会同步的把这些变化的数据记录到磁盘的redo log file日志文件中。如果对脏页中的数据进行同步失败了,就可以从redo log file文件去恢复数据了

redo log file在磁盘中其实有两份,它们是循环写的

undo log

回滚日志,用于记录数据被修改前的信息,作用包含两个:提供回滚MVCC(多版本并发控制))。undo log和redo log记录物理日志不一样,它是逻辑日志

  • 可以认为当delete一条记录时,undo log中会记录一条对应的insert记录,反之亦然,

  • 当update一条记录时,它记录一条对应相反的update记录。当执行rollback时,就可以从undo log中的逻辑记录读取到相应的内容并进行回滚。

undo log可以实现事务的一致性和原子性

总结:
undo log和redo log的区别
  • redo log:记录的是数据页的物理变化,服务宕机可用来同步数据

  • undo log:记录的是逻辑日志,当事务回滚时,通过逆操作恢复原来的数据

  • redo log保证了事务的持久性,undo log保证了事务的原子性和一致性

参考回答:

事务的隔离性是如何保证的呢?

锁:排他锁(如一个事务获取了一个数据行的排他锁,其他事务就不能再获取该行的其他锁)

mvcc:多版本并发控制

解释一下MVCC

全称 Multi-Version Concurrency Control,多版本并发控制。指维护一个数据的多个版本,使得读写操作没有冲突

MVCC的具体实现,主要依赖于数据库记录中的隐式字段undo log日志readView

了解了mvcc之后,我们就知道事务5的两条查询语句,分别查询的是哪个事务版本的记录了

不同的隔离级别访问的结果也会不太一样

MVCC-实现原理
记录中的隐藏字段

undo log

回滚日志,在insert、 update、delete的时候产生的便于数据回滚的日志。

当insert的时候,产生的undo log日志只在回滚时需要,在事务提交后,可被立即删除。

而update、 delete的时候,产生的undo log日志不仅在回滚时需要,mvcc版本访问也需要,不会立即被删除。

undo log版本链

不同事务或相同事务对同一条记录进行修改,会导致该记录的undolog生成一条记录版本链表,链表的头部是最新的旧记录,链表尾部是最早的旧记录。

readView

ReadView(读视图)是快照读SQL执行时MVCC提取数据的依据,记录并维护系统当前活跃的事务(未提交的)id。

  • 当前读

读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。对于我们日常的操作,如:select ... lock in share mode(共享锁),select ..for update、 update、 insert、delete(排他锁)都是一种当前读。

如图,事务A查询了两次id为1的数据,在这两次查询之间有一个事务B对id为1的数据进行了修改操作,并且提交了事务。如果是当前读的话,一般都会去加锁,读到的数据都是表中最新的数据,即时导致阻塞也要拿到最新的数据,所以第二次查询肯定是可以拿到事务B提交之后最新的数据的。

  • 快照读

简单的select(不加锁)就是快照读,快照读,读取的是记录数据的可见版本,有可能是历史数据,不加锁,是非阻塞读。

Read Committed:每次select,都生成一个快照读。

Repeatable Read:开启事务后第一个select语句才是快照读的地方。

还是上面这个图,快照读的话,不同的隔离级别,两次查询的数据会不太一样。

比如RC(Read Committed读已提交)的,是可以读到其它事务已经提交的数据的。

但是RR(Repeatable Read可重复读),在同一个事务内,不管查询多少次,都是相同的结果。获得的这个结果,就称为快照读

ReadView中包含了四个核心字段:

  • m_ids:

    • 表示的是当前活跃的事务ID集合,因为有可能会有多个事务没有提交,像图中事务五的第一个查询语句的位置,m_ids活跃事务就有三个,分别是事务3、事务4、事务5,因为查询这条记录的时候事务2已经提交了事务,活跃的事务只有3、4、5了。

  • min_trx_id

    • 表示的是当前最小活跃事务ID,像图中最小的就是事务3了,这个指的就是事务3

  • max_trx_id

    • 表示的是当前最大的事务ID+1,预分配事务ID,当前最大的就是事务5, 5+1就是6

  • creator_trx_id

    • 创建读视图的事务ID,目前是在事务五的这个位置查的,就是事务5创建的读视图,那创建者的事务ID就是事务5了

  • readview

不同的隔离级别,生成ReadView的时机不同:

  • READ COMMITTED:在事务中每一次执行快照读时生成ReadView。

  • REPEATABLE READ:仅在事务中第一次执行快照读时生成ReadView,后续复用该ReadView。

总结:
事务中的隔离性是如何保证的呢?(解释一下MVCC)

MysQL中的多版本并发控制。指维护一个数据的多个版本,使得读写操作没有冲突

  • 隐藏字段

    • ① trx id(事务id),记录每一次操作的事务id,是自增的

    • ② roll pointer(回滚指针),指向上一个版本的事务版本记录地址

  • undo log

    • ① 回滚日志,存储表版本数据

    • ② 版本链:多个事务并行操作某一行记录,记录不同事务修改数据的版本,通过rollpointer指针形成一个链表

  • readView解决的是一个事务查询选择版本的问题

    • 根据readView的匹配规则和当前的一些事务id判断该访问那个版本的数据

    • 不同的隔离级别快照读是不一样的,最终的访问的结果不一样

      • RC:每一次执行快照读时生成ReadView

      • RR:仅在事务中第一次执行快照读时生成ReadView,后续复用

参考回答:

2、主从同步原理

MySQL主从复制的核心就是二进制日志

二进制日志(BINLOG)记录了所有的DDL(数据定义语言)语句和 DML(数据操纵语言)语句,但不包括数据查询(SELECT、SHOW) 语句。

总结:

主从同步原理

MySQL主从复制的核心就是二进制日志binlog(DDL(数据定义语言)语句和 DML(数据操纵语言)语句)

  • ①主库在事务提交时,会把数据变更记录在二进制日志文件 Binlog 中。

  • ②从库读取主库的二进制日志文件 Binlog,写入到从库的中继日志 Relay Log。

  • ③从库重做中继日志中的事件,将改变反映它自己的数据。

参考回答:

3、分库分表

你们项目用过分库分表吗?

主从架构只能解决访问压力问题,解决不了海量数据存储问题

分库分表的时机:

  1. 前提,项目业务数据逐渐增多,或业务发展比较迅速 (单表的数据量达1000W20G以后)

  2. 优化已解决不了性能问题(主从读写分离、查询索引...)

  3. IO瓶颈(磁盘IO、网络IO)、CPU瓶颈(聚合查询、连接数太多)

拆分策略

垂直拆分
  • 垂直分库

垂直分库:以表为依据,根据业务将不同表拆分到不同库中。

特点:

  1. 按业务对数据分级管理、维护、监控、扩展

  2. 在高并发下,提高磁盘IO和数据量连接数

  • 垂直分表

垂直分表:以字段为依据,根据字段属性将不同字段拆分到不同表中。

特点:

  1. 冷热数据分离

  2. 减少IO过渡争抢,两表互不影响

水平拆分
  • 水平分库

水平分库:将一个库的数据拆分到多个库中。

特点:

  1. 解决了单库大数量,高并发的性能瓶颈问题

  2. 提高了系统的稳定性和可用性

  • 水平分表

水平分表:将一个表的数据拆分到多个表中(可以在同一个库内)。

特点:

  1. 优化单一表数据量过大而产生的性能问题;

  2. 避免lO争抢并减少锁表的几率;

分库分表的策略有哪些?
  • 新的问题和新的技术

总结:

你们项目用过分库分表吗?
  • 业务介绍

  1. 根据自己简历上的项目,想一个数据量较大业务(请求数多或业务累积大)

  2. 达到了什么样的量级(单表1000万或超过20G)

  • 具体拆分策略

  1. 水平分库,将一个库的数据拆分到多个库中,解决海量数据存储和高并发的问题 (sharding-sphere、mycat)

  2. 水平分表,解决单表存储和性能的问题 (sharding-sphere、mycat)

  3. 垂直分库,根据业务进行拆分,高并发下提高磁盘IO和网络连接数

  4. 垂直分表,冷热数据分离,多表互不影响


三、框架篇

一、Spring

1、Spring框架中的单例bean是线程安全的吗?

Spring框架中的bean是单例的吗?

是的,我们可以通过@Scope注解来设置bean是否是单例的。不设置的话默认就是singleton单例的

@Service
@Scope("singleton")
public class UserServiceImpl implements UserService {
}
  • singleton:bean在每个Spring IOC容器中只有一个实例。

  • prototype:一个bean的定义可以有多个实例。

Spring框架中的单例bean是线程安全的吗?

不是线程安全的,但是在某种程度上来说Spring的单例Bean又是线程安全的。

如图,count是一个成员变量,成员变量就应该考虑线程安全的问题。现在同时有多个请求过来,每个请求都能修改这个count,这时候它就不是线程安全的。像方法的形参id,这种局部变量一般情况下就没有线程安全问题的。

然后还有一个userService,它也是一个成员变量,但是因为它是一个无状态的类,是不能被修改的,无状态的类是没有线程安全问题的。

无状态:简单来说就是判断当前这个成员变量,它能不能被修改

总结:

Spring框架中的单例bean是线程安全的吗?

不是线程安全的

Spring框架中有一个@Scope注解,默认的值就是singleton,单例的。

因为一般在spring的bean的中都是注入无状态的对象,没有线程安全问题,如果在bean中定义了可修改的成员变是要考虑线程安全问题的,可以使用多例或者加锁来解决

参考回答:

2、什么是AOP,你们项目中有没有使用到AOP

  • 对AOP的理解

  • 有没有真的用过AOP

AOP称为面向切面编程,用于将那些与业务无关,但却对多个对象产生影响的公共行为和逻辑,抽取并封装为一个可重用的模块,这个模块被命名为"切面〞(Aspect),减少系统中的重复代码,降低了模块间的耦合度,同时提高了系统的可维护性。

常见的AOP使用场景:

  • 记录操作日志

  • 缓存处理

  • Spring中内置的事务处理

记录操作日志思路

代码实现案例

  • Application类

@SpringBootApplication
public class Application {
	public static void main(String[] args){
		SpringApplication.run(Application.class, args);
	}
}
  • UserController类

@RestController
@RequstMapping("/user")
public class UserController {
	@Autowired
	private UserService userService;

	@GetMapping("/getById/{id}")
	@Log(name = "根据用户id获取用户")
	public User getById(@PathVariable("id") Integer id) {
		return userService.getById(id);
	}

	public void save(User user){
		userService.save(user);
	}

	public void update(User user){
		userService.update(user);
	}

	public void delete(Integer id){
		userService.delete(id);
	}
}
  • Log自定义注解

package com.guqingge.annotation;

import java.lang.annotation.*;

@Target({ ElementType.PARAMENTER, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Log {
	/**
	 * 模块名称
     */
	public String name() default "";
}

  • SysAspect切面类

@Component
@Aspect //切面类
public class SysAspect {
	
	//切点表达式	
	@Pointcut("@annotation(com.guqingge.annotation.Log)")
	private void pointcut() {

	}

	@Around("pointcut()")
	public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
		//获取用户名
		//需要通过解析session或token获取

		//获取被增强类和方法的信息
		Signature signature = joinPoint.getSignature();
		MethodSignature methodSignature = (MethodSignature) signature;
		//获取被增强的方法对象
		Method method = methodSignature.getMethod();
		//从方法中解析注解
		if(method != null) {
			Log logAnnotation = method.getAnnotation(Log.class);
		System.out.println(logAnnotation.name());
	}
		//方法名字
		String name = method.getName();
		System.out.println(name);

		//通过工具类获取Request对象
		RequestAttributes reqa = RequestContextHolder.getRequestAttributes();
		ServletRequestAttributes sra = (ServletRequestAttributes)reqa;
		HttpServletRequest request = sra.getRequest();
		//访问的Unl
		String url = request.getRequestURI().toString();
		System.out.println(url);
		//请求方式
		String methodName = request.getMethod();
		System.out.println(methodName);

		//登灵IP
		String ipAddr = getIpAddr(request);
		System.out.println(ipAddr);

		//操作时间
		System.out.println(new Date());

		//保存到数据库(操作日志)
		//......

		return joinPoint.proceed();
	}

	/**
     * 获取客户端IP地址
     * @param request HttpServletRequest请求对象
     * @return 客户端IP地址
     */
    public String getIpAddr(HttpServletRequest request) {
        String ipAddress = request.getHeader("X-Forwarded-For");

        if (ipAddress == null || ipAddress.isEmpty() || "unknown".equalsIgnoreCase(ipAddress)) {
            ipAddress = request.getHeader("Proxy-Client-IP");
        }
        if (ipAddress == null || ipAddress.isEmpty() || "unknown".equalsIgnoreCase(ipAddress)) {
            ipAddress = request.getHeader("WL-Proxy-Client-IP");
        }
        if (ipAddress == null || ipAddress.isEmpty() || "unknown".equalsIgnoreCase(ipAddress)) {
            ipAddress = request.getHeader("HTTP_X_FORWARDED_FOR");
        }
        if (ipAddress == null || ipAddress.isEmpty() || "unknown".equalsIgnoreCase(ipAddress)) {
            ipAddress = request.getHeader("HTTP_X_FORWARDED");
        }
        if (ipAddress == null || ipAddress.isEmpty() || "unknown".equalsIgnoreCase(ipAddress)) {
            ipAddress = request.getHeader("HTTP_X_CLUSTER_CLIENT_IP");
        }
        if (ipAddress == null || ipAddress.isEmpty() || "unknown".equalsIgnoreCase(ipAddress)) {
            ipAddress = request.getHeader("HTTP_CLIENT_IP");
        }
        if (ipAddress == null || ipAddress.isEmpty() || "unknown".equalsIgnoreCase(ipAddress)) {
            ipAddress = request.getHeader("HTTP_FORWARDED_FOR");
        }
        if (ipAddress == null || ipAddress.isEmpty() || "unknown".equalsIgnoreCase(ipAddress)) {
            ipAddress = request.getHeader("HTTP_FORWARDED");
        }
        if (ipAddress == null || ipAddress.isEmpty() || "unknown".equalsIgnoreCase(ipAddress)) {
            ipAddress = request.getHeader("X-Real-IP");
        }
        if (ipAddress == null || ipAddress.isEmpty() || "unknown".equalsIgnoreCase(ipAddress)) {
            ipAddress = request.getRemoteAddr();
        }

        return ipAddress;
    }

}

Spring中的事务是如何实现的

Spring支持编程式事务管理和声明式事务管理两种方式。

  • 编程式事务控制:需使用TransactionTemplate来进行实现,对业务代码有侵入性,项目中很少使用

  • 声明式事务管理:声明式事务管理建立在AOP之上的。其本质是通过AOP功能,对方法前后进行拦截,将事务处理的功能编织到拦截的方法中,也就是在目标方法开始之前加入一个事务,在执行完目标方法之后根据执行情况提交或者回滚事务。

总结:

什么是AOP

面向切面编程,用于将那些与业务无关,但却对多个对象产生影响的公共行为和逻辑,抽取公共模块复用,降低耦合

你们项目中有没有使用到AOP

记录操作日志,缓存,spring实现的事务

核心是:使用aop中的环绕通知+切点表达式(找到要记录日志的方法),通过环绕通知的参数获取请求方法的参数(类、方法、注解、请求方式等),获取到这些参数以后,保存到数据库

Spring中的事务是如何实现的

其本质是通过AOP功能,对方法前后进行拦截,在执行方法之前开启事务,在执行完目标方法之后根据执行情况提交或者回滚事务。

参考回答:

3、Spring中事务失效的场景有哪些

对spring框架的深入理解、复杂业务的编码经验

  • 异常捕获处理

  • 抛出检查异常

  • 非public方法

情况一、异常捕获处理

情况二、抛出检查异常

情况三、非public方法导致的事务失效

总结:

Spring中事务失效的场景有哪些
  • ①异常捕获处理,自己处理了异常,没有抛出,解决:手动抛出

  • ②抛出检查异常,配置rollbackFor属性为Exception

  • ③非public方法导致的事务失效,改为public

参考回答:

**面试官**:Spring中事务失效的场景有哪些

**候选人**:

嗯!这个在项目中之前遇到过,我想想啊

第一个,如果方法上异常捕获处理,自己处理了异常,没有抛出,就会导致事务失效,所以一般处理了异常以后,别忘了跑出去就行了

第二个,如果方法抛出检查异常,如果报错也会导致事务失效,最后在spring事务的注解上,就是@Transactional上配置rollbackFor属性为Exception,这样别管是什么异常,都会回滚事务

第三,我之前还遇到过一个,如果方法上不是public修饰的,也会导致事务失效

嗯,就能想起来那么多

4、Spring的bean的生命周期

Spring容器是如何管理和创建bean实例

方便调试和解决问题

BeanDefinition

Spring容器在进行实例化时,会将xml配置的<bean>的信息封装成一个BeanDefinition对象,Spring根据BeanDefinition来创建Bean对象,里面有很多的属性用来描述Bean

  • User类

package com.itheima.lifecycle;

import org.springframework.beans.BeansException;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.beans.factory.BeanFactoryAware;
import org.springframework.beans.factory.BeanNameAware;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;

@Component
public class User implements BeanNameAware, BeanFactoryAware, ApplicationContextAware, InitializingBean {

    public User() {
        System.out.println("User的构造方法执行了.........");
    }

    private String name ;

    @Value("张三")
    public void setName(String name) {
        System.out.println("setName方法执行了.........");
    }

    @Override
    public void setBeanName(String name) {
        System.out.println("setBeanName方法执行了.........");
    }

    @Override
    public void setBeanFactory(BeanFactory beanFactory) throws BeansException {
        System.out.println("setBeanFactory方法执行了.........");
    }

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        System.out.println("setApplicationContext方法执行了........");
    }

    @PostConstruct
    public void init() {
        System.out.println("init方法执行了.................");
    }

    @Override
    public void afterPropertiesSet() throws Exception {
        System.out.println("afterPropertiesSet方法执行了........");
    }

    @PreDestroy
    public void destory() {
        System.out.println("destory方法执行了...............");
    }

}

第一步:构造函数

第二步:依赖注入

  • 像还有加了@Autowired注解的,也属于依赖注入

第三步:Aware接口

  • setBeanName可以拿到bean的名称

  • setBeanFactory拿到bean工厂

  • setApplicationContext可以拿到bean上下文ApplicationContext

第四步:BeanPostProcessor

  • 这里是没有实现的,看下面这个MyBeanPostProcessor类

  • MyBeanPostProcessor类

package com.itheima.lifecycle;

import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.cglib.proxy.Enhancer;
import org.springframework.cglib.proxy.InvocationHandler;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;

@Component
public class MyBeanPostProcessor implements BeanPostProcessor {

    @Override
    public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
        if (beanName.equals("user")) {
            System.out.println("postProcessBeforeInitialization方法执行了->user对象初始化方法前开始增强....");
        }
        return bean;
    }

    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
        if (beanName.equals("user")) {
            System.out.println("postProcessAfterInitialization->user对象初始化方法后开始增强....");
            //cglib代理对象
            /*Enhancer enhancer = new Enhancer();
            //设置需要增强的类
            enhancer.setSuperclass(bean.getClass());
            //执行回调方法,增强方法
            enhancer.setCallback(new InvocationHandler() {
                @Override
                public Object invoke(Object o, Method method, Object[] objects) throws Throwable {
                    //执行目标方法
                    return method.invoke(method,objects);
                }
            });
            //创建代理对象
            return enhancer.create();*/
        }
        return bean;
    }

}
  • 这个类实现了BeanPostProcessor

  • 这里面有两个方法,一个就是第四步的before前置方法,还有一个是after,初始化方法之后执行的

第五步:再看回到User这个类,第五步就是执行初始化方法

  • 首先这个加了@PostConstruct注解的init方法,就是我们自定义的初始化方法

  • 还有一个就是我们实现了InitializingBean这个接口,它就会需要去重写afterPropertiesSet这个方法,执行这个初始化的方法

第六步:再看回到MyBeanPostProcessor这个类,这里面就会执行postProcessAfterInitialization这个after的方法

第七步:这一步就是一个销毁的方法,如果说你的User类中,有加注解叫做@ProDestroy,它在容器关闭的时候就会去执行注解下的destory这个方法

测试演示

1.配置类

首先添加一个SpringConfig这么一个配置类,因为我们用的是注解,所以加了@Configuration。@ComponentScan扫描包,扫描的是MyBeanPostProcessor类和User类所在的包com.itheima.lifecycle

package com.itheima.config;

import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;

@Configuration
@ComponentScan("com.itheima.lifecycle")
public class SpringConfig {
}

2.定义测试类

配置类里,读取了当前的配置,从容器中拿到这个User对象

package com.itheima.lifecycle;


import com.itheima.config.SpringConfig;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;


public class UserTest {

    public static void main(String[] args) {
        ApplicationContext ctx = new AnnotationConfigApplicationContext(SpringConfig.class);
        User user = ctx.getBean(User.class);
        System.out.println(user);
    }

}

3.输出结果

可以看到,最后并没有执行最后一步的销毁的方法,因为我们在这里最后并没有把容器给关闭,所以它也不会去执行销毁的方法,我们重点还是说前面那几步其实就可以了。

还有一个就是后置处理器的after方法执行的时候,我们可以对当前的bean进行增强,就是使用AOP,AOP底层使用的是动态代理,可以看下面演示一下

  • 首先可以看到,目前的这个User它是一个正常的Bean,并不是一个代理对象

  • 然后在MyBeanPostProcessor类中,找到这个后置处理,就是postProcessAfterInitialization这个after方法,在这写好了一段代码

	@Override
    public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
        if (beanName.equals("user")) {
            System.out.println("postProcessAfterInitialization->user对象初始化方法后开始增强....");
            //cglib代理对象
            Enhancer enhancer = new Enhancer();
            //设置需要增强的类
            enhancer.setSuperclass(bean.getClass());
            //执行回调方法,增强方法
            enhancer.setCallback(new InvocationHandler() {
                @Override
                public Object invoke(Object o, Method method, Object[] objects) throws Throwable {
                    //执行目标方法
                    return method.invoke(method,objects);
                }
            });
            //创建代理对象
            return enhancer.create();
        }
        return bean;
    }
  • 解读一下,首先,如果当前是“user”这个对象,就进入到if的代码中,进行增强

  • 这个代码就是创建了一个cglib对象

  • 先是创建了一个Enhancer对象,这是cglib中的一个对象,然后指定当前需要增强的类,就是当前这个bean(bean.getClass())。然后执行了回调方法,这里面其实我们什么也没做,只是正常执行了一下方法

  • 但是,最后我们去返回了一个enhancer.create(),其实就是返回了一个代理对象。如果不是“user”的话,就正常返回bean对象

  • 再执行一下测试方法,可以看到user是一个代理对象了

  • 也就是说,我们要对类进行增强的话,就可以BeanPostProcessor的后置处理器针对于类进行功能的增强

总结:

Spring的bean的生命周期

  • ①通过BeanDefinition获取bean的定义信息

  • ②调用构造函数实例化bean

  • ③bean的依赖注入

  • ④处理Aware接口(BeanNameAware、 BeanFactoryAware、ApplicationContextAware)

  • ⑤Bean的后置处理器BeanPostProcessor-前置

  • ⑥初始化方法(InitializingBean、init-method)

  • ⑦Bean的后置处理器BeanPostProcessor-后置

  • ⑧销毁bean

参考回答:

5、Spring的bean的循环依赖

Spring中的循环引用

在创建A对象的同时需要使用的B对象,在创建B对象的同时需要使用到A对象

什么是Spring的循环依赖

三级缓存解决循环依赖

Spring解决循环依赖是通过三级缓存,对应的三级缓存如下所示:

一级缓存作用:限制bean在beanFactory中只存一份,即实现singleton scope,解决不了循环依赖

如果要想打破循环依赖,就需要一个中间人的参与,这个中间人就是二级缓存。

对象创建成功后存入到单例池中,在二级缓存中的半成品对象就会被清除掉

一般的对象可以通过一级缓存和二级缓存解决循环依赖的问题。假如A对象是一个代理对象,但是最终存入单例池中的并不是单例对象,所以说,如果一个对象是被增强的代理对象,这时候就需要借助三级缓存了

1.实例化对象A

2.对象A会生成一个ObjectFactory对象,即对象工厂对象

3.把工厂对象放入到一个三级缓存中,注意:这个对象工厂是用来专门产生对象A的

4.A对象需要注入对象B,B对象不存在,此时就会去实例化对象B

5.原始对象B也会去生成一个对象工厂对象ObjectFactory,同时也会放入到三级缓存中

6.对象B需要注入对象A,A对象还没有,但是三级缓存中有一个对象工厂,就会从三级缓存中获取A对象的ObjectFactory对象

7.ObjectFactory对象作用:如上面所说,目前的A对象是一个代理对象,那当前的ObjectFactory就能帮你生成一个代理对象,并且注入给对象B(如果不是代理对象,就生成一个普通的对象,注解注入给B)

8.所以之后的步骤就是通过A的对象工厂对象创建代理对象,然后存入到上面说的二级缓存中,它目前还是一个半成品,这时候就可以把生成好的这个代理对象,注入给对象B,那B对象就能创建成功了,B创建成功后就需要放到单例池中

9.B对象创建成功,那就可以注入给A对象了,这A对象也可以创建成功了,A对象也需要存入到单例池中

10.但是要注意,目前的单例池中的A对象是一个代理对象

11.这样,我们就借助了三级缓存解决了循环依赖的问题

构造方法出现了循环依赖怎么解决?

三级缓存可以解决初始化过程中的循环依赖问题,但是不能解决构造函数产生的循环依赖

总结:

Spring中的循环引用
  • 循环依赖:循环依赖其实就是循环引用,也就是两个或两个以上的bean互相持有对方,最终形成闭环。比如A依赖于B,B依赖于A

  • 循环依赖在spring中是允许存在,spring框架依据三级缓存已经解决了大部分的循环依赖

    • ①一级缓存:单例池,缓存已经经历了完整的生命周期,已经初始化完成的bean对象

    • ②二级缓存:缓存早期的bean对象(生命周期还没走完)

    • ③三级缓存:缓存的是ObjectFactory,表示对象工厂,用来创建某个对象的(原始对象或代理对象都可)

构造方法出现了循环依赖怎么解决?

A依赖于B,B依赖于A,注入的方式是构造函数

原因:由于bean的生命周期中构造函数是第一个执行的,spring框架并不能解决构造函数的的依赖注入

解决方案:使用@Lazy进行懒加载,什么时候需要对象再进行bean对象的创建

参考回答:

二、SpringMvc

SpringMvc的执行流程知道嘛

SpringMvc的执行流程是这个框架最核心的内容

  • 试图阶段(老旧JSP等)

  • 前后端分离阶段(接口开发,异步)

视图阶段(JSP)

前后端分离阶段(接口开发,异步请求)

总结:

SpringMvc的执行流程知道嘛
版本1:试图版本,jsp
  1. 用户发送出请求到前端控制器DispatcherServlet

  2. DispatcherServlet收到请求调用HandlerMapping(处理器映射器)

  3. HandlerMapping找到具体的处理器,生成处理器对象及处理器拦截器(如果有),再一起返回给DispatcherServlet。

  4. DispatcherServlet调用HandlerAdapter(处理器适配器)

  5. HandlerAdapter经过适配调用具体的处理器(Handler/Controller)

  6. Controller执行完成返回ModelAndView对象

  7. HandlerAdapter将Controller执行结果ModelAndView返回给DispatcherServlet

  8. DispatcherServlet将ModelAndView传给ViewReslover(视图解析器)

  9. ViewReslover解析后返回具体View(视图)

  10. DispatcherServlet根据View进行渲染视图(即将模型数据填充至视图中)

  11. DispatcherServlet响应用户

版本2:前后端开发,接口开发
  1. 用户发送出请求到前端控制器DispatcherServlet

  2. DispatcherServlet收到请求调用HandlerMapping(处理器映射器)

  3. HandlerMapping找到具体的处理器,生成处理器对象及处理器拦截器(如果有),再一起返回给DispatcherServlet。

  4. DispatcherServlet调用HandlerAdapter(处理器适配器)

  5. HandlerAdapter经过适配调用具体的处理器(Handler/Controller)

  6. 方法上添加了 @ResponseBody

  7. 通过HttpMessageConverter来返回结果转换为JSON并响应

参考回答:

三、SpringBoot

自动配置原理

Springboot中最高频的一道面试题,也是框架最核心的思想

总结:

SpringBoot自动配置原理

1.在Spring Boot项目中的引导类上有一个注解@SpringBootApplication,这个注解是对三个注解进行了封装,分别是:

  • @SpringBootConfiguration

  • @EnableAutoConfiguration

  • @ComponentScan

2.其中@EnableAutoConfiguration是实现自动化配置的核心注解。该注解通过@lmport注解导入对应的配置选择器。

内部就是读取了该项目和该项目引用的Jar包的的classpath路径下META-INF/spring.factories文件中的所配置的类的全类名。在这些配置类中所定义的Bean会根据条件注解所指定的条件来决定是否需要将其导入到Spring容器中。

3.条件判断会有像@ConditionalOnClass这样的注解,判断是否有对应的class文件,如果有则加载该类,把这个配置类的所有的Bean放入spring容器中使用。

参考回答:

Spring框架常见注解(Spring、Springboot、Springmvc)

  • Spring的常见注解有哪些?

  • SpringMvc常见的注解有哪些?

  • Springboot常见注解有哪些?

Spring的常见注解有哪些?

SpringMvc常见的注解有哪些?

Springboot常见注解有哪些?

参考回答:

四、MyBatis

MyBatis执行流程

mybatis-config.xml文件

MappedSttement对象就是封装当前某一个标签的,代表了某一次数据库操作

总结:

MyBatis执行流程

  1. 读取MyBatis配置文件:mybatis-config.xml加载运行环境和映射文件

  2. 构造会话工厂SqlSessionFactory

  3. 会话工厂创建SqlSession对象(包含了执行SQL语句的所有方法)

  4. 操作数据库的接口,Executor执行器,同时负责查询缓存的维护

  5. Executor接口的执行方法中有一个MappedStatement类型的参数,封裝了映射信息

  6. 输入参数映射

  7. 输出结果映射

参考回答:

MyBatis是否支持延迟加载?

  • MyBatis支持延迟加载,但默认没有开启

  • 什么叫做延迟加载?

什么叫做延迟加载?

查询用户的时候,把用户所属的订单数据也查询出来,这个是立即加载

查询用户的时候,暂时不查询订单数据,当需要订单的时候,再查询订单,这个就是延迟加载

局部延迟加载

全局延迟加载

延迟加载的原理

  1. 使用CGLIB创建目标对象的代理对象

  2. 当调用目标方法usergetOrderList0时,进入拦截器invoke方法,发现usergetOrderList0是null值,执行sql查询order列表

  3. 把order查询上来,然后调用user.setOrderList(List<Order> orderList),接着完成usergetOrderList()方法的调用

总结:

MyBatis是否支持延迟加载?
  • 延迟加载的意思是:就是在需要用到数据时才进行加载,不需要用到数据时就不加载数据。

  • Mybatis支持一对一关联对象和一对多关联集合对象的延迟加载

  • 在Mybatis配置文件中,可以配置是否启用延迟加载lazyLoadingEnabled=true|false,默认是关闭的

延迟加载的底层原理知道吗?
  1. 使用CGLIB创建目标对象的代理对象

  2. 当调用目标方法时,进入拦截器invoke方法,发现目标方法是null值,执行sql查询

  3. 获取数据以后,调用set方法设置属性值,再继续查询目标方法,就有值了

参考回答:

MyBatis的一级、二级缓存用过吗?

  • 本地缓存,基于PerpetualCache,本质是一个HashMap

  • 一级缓存:作用域是session级别

  • 二级缓存:作用域是namespace和mapper的作用域,不依赖于session

一级缓存

一级缓存:基于 PerpetualCache 的 HashMap 本地缓存,其存储作用域为 Session,当Session进行flush或close之后,该Session中的所有Cache就将清空,默认打开一级缓存

  • 如图,这从SqlSessionFactory中拿到了一个SqlSession,然后从这里面拿到了两个Mapper分别是userMapper1和userMapper2,这两个都是同一个UserMapper.class。

  • 分别用userMapper1和userMapper2查询Id为6的用户,这两条查询的是相同的id,这时候一级缓存就起作用了。

  • 因为第一次查询的时候,会把数据保存到本地的缓存中,也就是HashMap中,第二次查询的时候就不会再去执行sql了,直接从缓存中返回数据。

二级缓存

二级缓存是基于namespace和mapper的作用域起作用的,不是依赖于SQL session, 默认也是采用 PerpetualCache,HashMap 存储

  • 如图,这段代码中是打开了两次SqlSession,分别是sqlSession1和sqlSession2,然后分别去查询了id为6的数据,但是这个和上面不一样,这次使用的两个UserMapper分别属于sqlSession1和sqlSession2这两个不同的会话。

  • 那最终的结果就会是执行了两次sql。

  • 如果我们只想执行一次sql,那就要开启二级缓存了。

二级缓存默认是关闭的

开启方式,两步:

1.全局配置文件

2.映射文件

使用<cache/>标签让当前mapper生效二级缓存


测试可以看到,Cache Hit Ratio,命中了索引,是UserMapper级别的,证明当前第二次查询,是从缓存中去获取的数据

注意事项:

1、对于缓存数据更新机制,当某一个作用域(一级缓存 Session/二级缓存Namespaces)的进行了新增、修改、删除操作后,默认该作用域下所有 select 中的缓存将被 clear

2、二级缓存需要缓存的数据实现Serializable接口

3、只有会话提交或者关闭以后,一级缓存中的数据才会转移到二级缓存中

如图只有第一个会话被关闭了,才能用第二个去查询缓存中的数据。

总结:

MyBatis的一级、二级缓存用过吗?
  • 一级缓存:基于 PerpetualCache 的 HashMap 本地缓存,其存储作用域为 Session,当Session进行flush或close之后,该Session中的所有Cache就将清空,默认打开一级缓存

  • 二级缓存是基于namespace和mapper的作用域起作用的,不是依赖于SQL session, 默认也是采用PerpetualCache, HashMap 存储。需要单独开启,开启需要两步,一个是核心配置,一个是mapper映射文件

MyBatis的二级缓存什么时候会清理缓存中的数据?

当某一个作用域(一级缓存 Session/二级缓存Namespaces)的进行了新增、修改、删除操作后,默认该作用域下所有 select 中的缓存将被 clear。

参考回答:


四、微服务篇

一、SpringCloud

1、SpringCloud 5大组件有哪些?

通常情况下:

  • Eurek:注册中心

  • Ribbon:负载均衡

  • Feign:远程调用

  • Hystrix:服务熔断

  • Zuul/Gateway:网关

随着SpringCloudAlibaba在国内兴起,我们项目中使用了一些阿里巴巴的组件

  • 注册中心/配置中心:Nacos

  • 负载均衡:Ribbon

  • 服务调用:Feign

  • 服务保护:sentinel

  • 服务网关:Gateway

参考回答:

2、服务注册和发现是什么意思?SpringCloud如何实现服务注册发现?

  • 微服务中必须要使用的组件,考察我们使用微服务的程度

  • 注册中心的核心作用是:服务注册和发现

  • 常见的注册中心:eureka、nacos、zookeeper(还有consul)

Eureka的作用

总结:

服务注册和发现是什么意思?SpringCloud如何实现服务注册发现?
  • 我们当时项目采用的eureka作为注册中心,这个也是spring cloud体系中的一个核心组件

  • 服务注册:服务提供者需要把自己的信息注册到eureka,由eureka来保存这些信息,比如服务名称、ip、端口等等

  • 服务发现:消费者向eureka拉取服务列表信息,如果服务提供者有集群,则消费者会利用负载均衡算法,选择一个发起调用

  • 服务监控:服务提供者会每隔30秒向eureka发送心跳,报告健康状态,如果eureka服务90秒没接收到心跳,从eureka中剔除

参考回答:

3、我看你之前也用过nacos、你能说下nacos与eureka的区别?

Nacos的工作流程

nacos的服务提供者也会定期发送心跳到nacos注册中心,证明当前某个节点是存活的。不同的是,这里有一个名词,叫临时实例。

如图,这个是正常在微服务中配置nacos的一种方式,注意到这里有一个ephemeral,这个的含义就是表示临时的,我们通常都不会去设置它,默认值就是true,就是说我们平时创建的实例都是临时实例。

假如说我们在这里设置了这个ephemeral,并且把值改为了false,那这个实例就是非临时实例了。

如果是临时实例,那这个工作流程,和Eureka是一样的,其中健康监测也是通过心跳去做健康监测的。

如果ephemeral设置为了false,那当前的实例就是非临时实例,非临时实例,nacos注册中心会主动去询问,看当前的服务提供者是否存活。而Eureka中是没有非临时实例这个概念的,这是第一个不同。

第二个不同就是,如果当前某个服务的地址发生了变更了,那nacos注册中心会主动推送变更消息,就是说nacos不但但只有pull,还有push。

就是正常是服务消费者取pull拉取信息,假如说服务提供者信息发生改变之后,那nacos会主动去push推送,使服务列表的更新更加的及时。

总结:

我看你之前也用过nacos、你能说下nacos与eureka的区别?
  • Nacos与eureka的共同点(注册中心)

    • ① 都支持服务注册和服务拉取

    • ② 都支持服务提供者心跳方式做健康检测

  • Nacos与Eur以ka的区别(注册中心)

    • ① Nacos支持服务端主动检测提供者状态:临时实例采用心跳模式,非临时实例采用主动检测模式

    • ② 临时实例心跳不正常会被剔除,非临时实例则不会被剔除

    • ③Nacos支持服务列表变更的消息推送模式,服务列表更新更及时

    • ④Nacos集群默认采用AP方式(高可用),当集群中存在非临时实例时,采用CP模式(强一致);Eureka采用AP方式

  • Nacos还支持了配置中心,eureka则只有注册中心,也是选择使用nacos的一个重要原因

参考回答:

4、你们项目负载均衡如何实现的?

  • 负载均衡Ribbon,发起远程调用feign就会使用Ribbon

  • Ribbon负载均衡策略有哪些?

  • 如果想自定义负载均衡策略如何实现?

Ribbon负载均衡流程

Ribbon负载均衡策略有哪些?

  • RoundRobinRule:简单轮询服务列表来选择服务器

  • WeightedResponseTimeRule:按照权重来选择服务器,响应时间越长,权重越小

  • RandomRule:随机选择一个可用的服务器

  • BestAvailableRule:忽略那些短路的服务器,并选择并发数较低的服务器

  • RetryRule:重试机制的选择逻辑

  • AvailabilityFilteringRule:可用性敏感策略,先过滤非健康的,再选择连接数较小的实例

  • ZoneAvoidanceRule:以区域可用的服务器为基础进行服务器的选择。使用Zone对服务器进行分类,这个Zone可以理解为一个机房、一个机架等。而后再对Zone内的多个服务做轮询(Ribbon默认的策略,如果没有区域这个概念,那还是会用轮询的这个方式进行远程调用)

如果像自定义负载均衡如何实现?

可以自己创建类实现IRule接口,然后再通过配置类或者配置文件配置即可,通过定义IRule实现可以修改负载均衡规则,有两种方式:

要实现自定义负载均衡策略,都是在服务的发起方进行配置

全局生效:

如图,第一种方式,可以加一个配置类,在配置类中返回一个IRule接口的实现。IRule接口实现类就是上面那几种不同的负载均衡策略的实现,都是可以找到的,不同的实现类就是不同的负载均衡策略。

局部生效

如图红框部分的配置,在yml配置文件中,指定要调用的服务,用的是哪一种负载均衡策略。和上面的方式不同的是,这个只对指定调用的这个服务生效

总结:

你们项目负载均衡如何实现的?

微服务的负载均衡主要使用了一个组件Ribbon,比如,我们在使用feign远程调用的过程中,底层的负载均衡就是使用了ribbon

不过现在大多用的是loadBalance

Ribbon负载均衡策略有哪些?
  • RoundRobinRule:简单轮询服务列表来选择服务器

  • WeightedResponseTimeRule:按照权重来选择服务器,响应时间越长,权重越小

  • RandomRule:随机选择一个可用的服务器

  • ZoneAvoidanceRule:区域敏感策略,以区域可用的服务器为基础进行服务器的选择。使用Zone对服务器进行分类,这个Zone可以理解为一个机房、一个机架等。而后再对Zone内的多个服务做轮询(默认)

如何想自定义负载均衡策略如何实现?

提供了两种方式:

  1. 创建类实现IRule接口,可以指定负载均衡策略(全局)

  2. 在客户端的配置文件中,可以配置某一个服务调用的负载均衡策略(局部)

参考回答:

5、什么是服务雪崩,怎么解决这个问题?

  • 什么是服务雪崩

  • 熔断降级(解决) Hystix 服务熔断降级

  • 限流(预防)

如图,一个微服务项目中,可能存在众多的微服务,各微服务之间的通信,都有可能会发生feign的远程调用。如果某一个服务挂了之后,就有可能会导致服务雪崩。

比如服务D首先挂了,服务A去调用服务D的时候肯定也是失败的,如果这时候服务A还不断的去向服务A去发起请求,要知道一个服务的连接数是一定量的,调用失败的一些连接并没有释放,服务A的连接数被占满之后,就不能对外提供服务了,也就是说服务A也宕机了,这个可能是一个连锁反应,其它服务调用服务A的时候也是同样的情况,就有可能因为服务D的宕机导致整个微服务都不可用,这个就是服务雪崩。

服务降级

服务降级是服务自我保护的一种方式,或者保护下游服务的一种方式,用于确保服务不会受请求突增影响变得不可用,确保服务不会崩溃

如图,服务D有两个接口,一个修改和一个保存。其中修改的接口是正常可以访问的,保存的接口是有问题的,比如抛了异常。现在服务A要去调用服务D,假如调用的是修改,是可以正常访问的,如果调用的是保存,因为保存的接口是失败的,所以说这时候我们就可以加入降级的逻辑了。比如我们可以加入一句话提示,您的网络有问题,请稍后再试。有了降级之后,这个接口它就不通了,也就是说目前我们这个保存的功能就没有了,这个就是服务降级。

具体代码实现

我们一般情况下都是使用feign接口发起远程调用的时候去做降级的。

这里有一个FeignClient,我们要调用的是leadnews-article这个文章微服务,我们在这设置了一个fallback,去指定了当前降级的逻辑。哪个降级呢,以当前feign接口中有什么内容,在下面都能去定义这个降级的逻辑,这两个是有关联的。

可以看到fallback指定的这个IArticleClientFallback.class跟下面的这个类是一样的,它们是有对应关系的,看feign接口的方法和IArticleClientFallback类中的这个方法是一样的,也就是说,当这个服务接口可以正常访问的时候我们走的就是feign的远程调用,假如说这个接口调用失败了,抛异常了,这个时候我们就可以走降级的逻辑,就是下面的直接返回“获取数据失败”。

还有一个问题就是,假如说当前的这个保存接口已经降级了,如果现在还是有大量的请求进来之后,就有可能会触发熔断机制。

服务熔断

Hystrix 熔断机制,用于监控微服务调用情况,默认是关闭的,如果需要开启需要在引导类上添加注解:@EnableCircuitBreaker

如果检测到 10秒内请求的失败率超过 50%,就触发熔断机制。之后每隔5秒重新尝试请求微服务,如果微服务不能响应,继续走熔断机制。如果微服务可达,则关闭熔断机制,恢复正常请求

  • 断路器

如图这个断路器的三个状态,正常情况这个熔断机制它都是Closed关闭状态,像上面服务D的保存接口,发生了降级的逻辑,假如说请求太多了,达到了一定的阈值,它就会触发熔断机制(检测到 10秒内请求的失败率超过 50%),当前的这个断路器就从关闭状态到了打开状态。假如说断路器打开之后,当前的这个服务整体就不能用了,比如上面的服务D,本来只有保存接口是失败的,修改接口是正常的,断路器打开后,整个服务就不可用了,修改接口也没办法进行调用,这就是断路器打开之后的一个情况。

当然这里断路器也不会一直打开,这里还有第三个状态,半开状态。经过一段时间之后,熔断时间接口,它会进入到半开状态,半开状态下它会尝试放行一次请求,假如当前这一次的请求是失败的,它会继续打开这个断路器,再来经过一段时间等熔断时间结束后再尝试,这个时间默认是每隔5秒去尝试一次。如果5秒后再去尝试放行一次请求,请求成功之后,它就会把这个断路器给关闭了,这时候所有的请求就可以继续去访问服务D了。

总结:

什么是服务雪崩,怎么解决这个问题?
  • 服务雪崩:一个服务失败,导致整条链路的服务都失败的情形

  • 服务降级:服务自我保护的一种方式,或者保护下游服务的一种方式,用于确保服务不会受请求突增影响变得不可用,确保服务不会崩溃,一般在实际开发中与feign接口整合,编写降级逻辑服务降级针对的是某一个接口

  • 服务熔断:默认关闭,需要手动打开,如果检测到 10秒内请求的失败率超过 50%,就触发熔断机制。之后每隔5秒重新尝试请求微服务,如果微服务不能响应,继续走熔断机制。如果微服务可达,则关闭熔断机制,恢复正常请求(服务熔断针对的是整个服务

参考回答:

6、你们的微服务时怎么监控的?

为什么需要监控?

skywalking

一个分布式系统的应用程序性能监控工具(Application Performance Managment),提供了完善的链路追踪能力apache的顶级项目(前华为产品经理吴晟主导开源)

  • 服务(service):业务资源应用系统(微服务)

  • 端点(endpoint):应用系统对外暴露的功能接口(接口)

  • 实例(instance):物理机

问题定位

先看仪表盘,从左到右

  • 第一个是Services Load,这个展示的是目前已经集成SkyWalking的微服务有哪些

  • 第二个是Slow Services,展示的是比较慢的微服务

  • 第三个是Un-Health Services ,这个是不健康的服务

  • 第四个是Slow Endpoint,展示的是比较慢的接口

有了这些信息,我们就能定位到哪些微服务比较慢,哪些微服务不健康,还有哪个接口比较慢,就可以针对性进行优化,这就解决了上面第一个问题定位的问题了。

性能分析

比如我们看到这个接口是比较慢的,我们点到skywalking顶栏的追踪栏。

可以看到第一个就是比较慢的这个接口,这个接口经过了两个微服务,第一个是app-gateway网关,第二个是article文章微服务,右边紫色的条都是网关做加载的时间,蓝色部分是文章服务加载的时间,我们从下往上看。

可以看到最下面的两个是连接数据库的东西,它们的耗时是非常短的,虽然有蓝色的部分耗时,但几乎是看不到的,可以说明数据库连接的部分是没有问题的。

再往上看,这个比较长的蓝色条对应的接口/article/load,这个加载的时间就比较长了,这是SpringMvc里面的,所以大概可以断定为是SpringMVC里面的问题,因为这里比较慢,所以上面网关的时间也会比较慢。

这里还能展示更多的信息,比如找到数据库连接这一块,点击Mysql/JDBl/PreparedStatement/execute这里,会发现当前这个接口的查询的sql语句也会展示出来,假如是sql的问题,那它的耗时肯定会更长一些,然后我们就可以使用EXPLAIN执行计划去分析当前sql那一块有问题。

服务关系

服务关系可以点击顶栏的拓扑图,就可以清晰的看到服务和服务之间的关系

服务告警

找到顶栏最后一个告警

告警这一块有告警的规则,规则有很多,下面的默认的其中5个

总结:

你们的微服务时怎么监控的?

我们项目中采用的skywalking进行监控的

1, skywalking主要可以监控接口、服务、物理实例的一些状态。特别是在压测的时候可以看到众多服务中哪些服务和接口比较慢,我们可以针对性的分析和优化。

2, 我们还在skywalking设置了告警规则,特别是在项目上线以后,如果报错,我们分别设置了可以给相关负责人发短信和发邮件,第一时间知道项目的bug情况,第一时间修复

参考回答:

二、业务相关

1、你们项目中有没有做过限流?怎么做的?

为什么要限流?

1.并发的确大(突发流量)

2.防止用户恶意刷接口

限流的实现方式

  • Tomca:可以设置最大连接数(单体项目是可以的,如果是微服务架构就不适合了)

  • Nginx,漏桶算法

  • 网关,令牌桶算法

  • 自定义拦截器

Nginx限流

控制速率(突发流量)

漏桶算法的工作方式:如图,水滴代表请求的流量,桶中存储的就是请求。漏桶以固定速率漏出请求。比如来了100个请求,我们会把它存储到漏桶中,然后固定的往外流出,比如每秒只流出两个请求,那这时候我们处理请求的速率就比较稳定了。

还有就是这个桶是有固定大小的,比如它只能存储100个请求,如果现在来了101个或者102个请求,超过100的请求都会让它去等待或者直接抛弃,即只有进入了桶中的流量才能得到处理,并且是以固定的速率来处理这些请求。

  • 语法:limit_req_zone key zone rate (limit_req_zone表示的是我们要设置一个请求的区域,key是定义限流对象,binary_remote_addr就是一种可以,基于ip限流,也可以理解为基于用户进行限流,zone是区域,zone中的service1RateLimit是自定义的一段标识,下面的location中使用的时候要去指定当前的这个某一段标识,冒号后的10m是表示申请了多大的存储空间,10m可以存储16万ip地址访问信息,1m就可以存储1万6个ip地址。最后的rate=10r/s表示每秒最多处理10个请求)

  • key:定义限流对象,binary_remote_addr就是一种key,基于客户端ip限流

  • Zone:定义共享存储区来存储访问信息,10m可以存储16wip地址访问信息

  • Rate:最大访问速率,rate=10r/s 表示每秒最多请求10个请求(相同ip)

  • burst=20:相当于桶的大小

  • Nodelay:快速处理(桶满了之后后面来的请求会快速抛弃)

控制并发连接数

  • limit_conn perip 20:对应的key是 $binary_ remote_addr,表示限制单个IP同时最多能持有20个连接。

  • limit_ conn perserver 100:对应的key是 $server_name,表示虚拟主机(server) 同时能处理并发连接的总数。

网关限流

yml配置文件中,微服务路由设置添加局部过滤器RequestRateLimiter

令牌桶算法工作方式:如图,令牌桶和上面的漏桶有一点相似,不过不同的是,令牌桶存储的是令牌,漏桶存储的是请求。令牌桶算法是以固定速率生成令牌,存入令牌桶,桶满后暂停生成。如果这时候请求来了,需要到令牌桶中申请令牌才行,申请到令牌的请求才会被服务处理,没有申请到令牌的请求,会被阻塞或丢弃。

漏桶存储请求,令牌桶存储令牌,前者能够以恒定的速率进行放行,后者能处理突发流量,意思就是令牌桶可能会在一瞬间放行大量请求,漏桶则是每隔多久就放行一部分比较平滑

  • key-resolver:定义限流对象(ip、路径、参数),需代码实现,使用spel表达式获取

  • replenishRate:令牌桶每秒填充平均速率。

  • urstCapacity:令牌桶总容量。

总结:

你们项目中有没有做过限流?怎么做的?

1.先来介绍业务,什么情况下去做限流,需要说明QPS具体多少

  • 我们当时有一个活动,到了假期就会抢购优惠券,QPS最高可以达到2000,平时10-50之间,为了应对突发流量,需要做限流

  • 常规限流,为了防止恶意攻击,保护系统正常运行,我们当时系统能够承受最大的QPS是多少(压测结果)

2.nginx限流

  • 控制速率(突发流量),使用的漏桶算法来实现过滤,让请求以固定的速率处理请求,可以应对突发流量

  • 控制并发数,限制单个ip的链接数和并发链接的总数

3.网关限流

  • 在spring cloud gateway中支持局部过滤器RequestRateLimiter来做限流,使用的是令牌桶算法

  • 可以根据ip或路径进行限流,可以设置每秒填充平均速率,和令牌桶总容量

限流常见的算法有哪些?
  • 漏桶算法

  • 令牌桶算法

  • 可以分别说一下这两个的工作方式和区别即可

参考回答:

2、解释一下CAP和BASE

  • 分布式事务方案的指导

  • 分布式系统设计方向

  • 根据业务指导使用正确的技术选择

  • CAP定理

  • BASE理论

CAP定理

1998年,加州大学的计算机科学家 Eric Brewer 提出,分布式系统有三个指标:

  • Consistency (一致性)

  • Availability(可用性)

  • Partition tolerance(分区容错性)

Eric Brewer 说,分布式系统无法同时满足这三个指标。

这个结论就叫做 CAP 定理。

CAP定理-Consistency

Consistency(一致性):用户访问分布式系统中的任意节点,得到的数据必须一致

比如现在有两个节点,node01和node02,它们的数据data都是v0,现在有一个客户端要去访问者两个节点,无论访问哪一个,得到的结果都是一样的。假如现在node01的数据变了,变成了v1,那现在访问node02的时候,两个节点的数据就不一样了。所以说为了保证数据的一致性,这时候node01要跟node02进行数据同步。这个就像mysql的那个主从复制,要去保证主节点和从节点数据保证一致才行。这个就是一致性

CAP-Availability

Availability(可用性):用户访问集群中的任意健康节点,必须能得到响应,而不是超时或拒绝

如图,目前有三个节点node01,node02,node02,当客户端去访问的时候,这三个节点必须要都能响应给客户。假如现在node03不知道什么原因出现了阻塞或拒绝,所有请求都无法访问了,这时候node03就不可用了。所以可用性是指着这个节点能不能正常的访问,这个就是可用性

CAP-Partition tolerance

Partition(分区):因为网络故障或其它原因导致分布式系统中的部分节点与其它节点失去连接,形成独立分区。

Tolerance(容错):在集群出现分区时,整个系统也要持续对外提供服务

如图,目前有三个节点node01,node02,node02,假如现在忽然网络发生故障了,node03和其它两个节点断开了连接,这时候就形成了两个分区,左边了node01和node02,它们是一个分区,node03是在另外一个分区。现在假如说客户端给node02进行了写数据操作,将data的数据从v0改为了v1,这时候它们在进行数据同步的时候,node02可以找到node01,进行数据同步,但是却找不到node03去进行数据同步,因为它们属于两个不同的网络分区。这样就会出现数据不一致的问题。

分区容错又是什么意思呢,意思就是尽管这里网络分区了,用户该访问还是要继续访问的。但是如果现在要访问的是node01,它拿到的结果和node03肯定是不一样的,这时候就可能会出现数据不一致的情况,没有满足上面说的一致性。

如果一定要满足一致性该怎么办呢,我们可以这样,当访问node03的时候,让客户端先等待一下,等待这个网络恢复,让node02把数据同步到node03之后,再将数据返回给客户端,这时候就能满足数据的一致性了。

但是这样又会出现另外一个问题,就是因为访问node03的时候,需要等待,就卡在这里了,因为不让客户端访问了,所以node03这时候就变成了不可用了,这样就满足不了上面说的可用性了

整体来说就是,我们要想满足可用性,就没有办法满足一致性。要保证一致性,就没办法保证可用性。

结论
  • 分布式系统节点之间肯定是需要网络连接的,分区(P)是必然存在的

  • 如果保证访问的高可用性(A),可以持续对外提供服务,但不能保证数据的强一致性--> AP

  • 如果保证访问的数据强一致性(C),就要放弃高可用性 --> CP

BASE理论

BASE理论是对CAP的一种解决思路,包含三个思想:

  • Basically Available(基本可用):分布式系统在出现故障时,允许损失部分可用性,即保证核心可用。

  • Soft State(软状态):在一定时间内,允许出现中间状态,比如临时的不一致状态。

  • Eventually Consistent(最终一致性):虽然无法保证强一致性,但是在软状态结束后,最终达到数据一致。

举个例子,如图这是一个下单的业务,这个业务需要经过几个微服务才能完成这个业务,里面就涉及到了分布式事务的问题了。当用户下单之后,首先要在订单微服务之中完成保存。然后订单微服务需要远程调用账户微服务进行扣款,再之后需要远程调用库存微服务进行扣减库存,这一个业务就经过了三个微服务。

假如现在库存不足了,扣减库存失败了。那我们前面的这些操作就无效了,我们需要把数据进行回滚,简单来说就是这些操作虽然不再同一个微服务下,但是我们也要保证要么同时成功,要么同时失败,这个就是分布式事务要完成的事情。

这个和上面说的CAP和BASE又有什么关系呢?因为目前这三个微服务都是通过网络连接的,它们是一个分布式的项目,既然是分布式项目,如果要完成通信,那就要去做出选择了,要么选择AP,要么选择CP。

也就说我们可以选择CAP和BASE去指导我们去解决这些分布式事务的问题。假如我们现在选择的是AP,也就是满足了可用性,牺牲了一定的一致性。比如说现在各个子事务,每个微服务都是一个子事务,将来我们去执行的时候,我们分别去执行和提交,注意是把各自的事务都提交了,这些微服务有的成功了,有的失败了,比如下单和扣款成功了,扣减库存失败了。这时候它们的状态就不一致了,这时候就处于了一个软状态,临时的不一致的状态。那各个子事务执行完成之后,是不是要互相看一下,同步一下自己的状态呢,这时候就需要用到事务协调器了,由它来统一管理这些事务的状态。如果发现有的事务失败了,那事务协调器会通知已经提交的这些事务,说需要把数据再恢复过来,当然这里恢复数据并不是回滚事务,因为我们之前已经把事务给提交了,所以这时候需要逆向操作,比如之前新增了一个订单,那这时候我们只需要把这个订单给它删除了不就行了吗。这样这个数据不就恢复到了原来的状态了吗,这样就实现了最终一致性。所以这种模式其实就是一种AP的思想。(像是补偿机制)

那反过来其实也是一样的,可以达到强一致性。CP的思想。就是各个子事务分别去执行自己的业务,但是这时候要注意,跟我们AP不一样的点在于我们各个子事务并不会直接提交事务,因为提交完成之后这个事务就没有办法回滚了对不对。现在各个子事务只需要执行完成,别提交,需要互相的等待,然后把各自的执行情况上报给事务协调器。假如现在全部都已经执行完成了,那事务协调器一看三个都成功了,才会去通知这三个服务去提交事务。如果有一个失败了,那事务协调器会通知各个微服务去回滚事务就可以了,这样就可以达成强一致性了。因为这里面可没有中间状态,只不过这个过程中各个子事务是需要等待彼此的执行的,所以在这个等待的过程中,其实各个微服务都处于一个弱可用的状态,因为在等待的过程中会锁定资源导致其它的线程无法访问。

总结:

解释一下CAP和BASE

CAP 定理(一致性、可用性、分区容错性)

  • 1.分布式系统节点通过网络连接,一定会出现分区问题(P)

  • 2.当分区出现时,系统的一致性(C)和可用性(A)就无法同时满足

BASE理论

  • 1.基本可用

  • 2.软状态

  • 3.最终一致

解决分布式事务的思想和模型:

  • 1.最终一致思想:各分支事务分别执行并提交,如果有不一致的情况,再想办法恢复数据 (AP)

  • 2.强一致思想:各分支事务行完业务不要提交,等待彼此结果。而后统一提交或回滚 (CP)

参考回答:

3、你们采用哪种分布式事务解决方案?

简历上写的是微服务项目

Seata框架(XA、AT、TCC)

MQ

Seata架构

Seata事务管理中有三个重要的角色
  • TC (Transaction Coordinator)- 事务协调者:维护全局和分支事务的状态,协调全局事务提交或回滚。

  • TM (Transaction Manager)- 事务管理器:定义全局事务的范围、开始全局事务、提交或回滚全局事务。

  • RM (Resource Manager)- 资源管理器:管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。

Seata的XA模式
  • RM一阶段的工作:

    • ① 注册分支事务到TC

    • ② 执行分支业务sql但不提交

    • ③ 报告执行状态到TC

  • TC二阶段的工作:

    • TC检测各分支事务执行状态

      • a.如果都成功,通知所有RM提交事务

      • b.如果有失败,通知所有RM回滚事务

  • RM二阶段的工作

    • 接收TC指令,提交或回滚事务

AT模式原理

AT模式同样是分阶段提交的事务模型,不过缺弥补了XA模型中资源锁定周期过长的缺陷。

  • 阶段—RM的工作:

    • 注册分支事务

    • 记录undo-log(数据快照)

    • 执行业务Sql并提交

    • 报告事务状态

  • 阶段二提交时RM的工作:

    • 删除undo-log即可

  • 阶段二回滚时RM的工作:

    • 根据undo-log恢复数据到更新前

TCC模式原理

1、 Try:资源的检测和预留;

2、Confirm:完成资源操作业务;要求 Try 成功 Confirm 一定要能成功。

3、Cancel:预留资源释放,可以理解为try的反向操作。

MQ分布式事务

总结:

你们采用了哪种分布式事务解决方案?
  • 简历上写的微服务,只要是发生了多个服务之间的写操作,都需要进行分布式事务控制

  • 描述项目中采用的哪种方案 (seata I MQ)

    • 1.seata的XA模式,CP,需要互相等待各个分支事务提交,可以保证强一致性,性能差 (银行业务)

    • 2.seata的AT模式,AP,底层使用undo log 实现,性能好 (互联网业务)

    • 3.seata的TCC模式,AP,性能较好,不过需要人工编码实现 (银行业务)

    • 4.MQ模式实现分布式事务,在A服务写数据的时候,需要在同一个事务内发送消息到另外一个事务,异步,性能最好 (互联网业务)

参考回答:

4、分布式服务的接口幂等性如何设计?

幂等:多次调用方法或者接口不会改变业务状态,可以保证重复调用的结果和单次调用的结果一致。

需要幂等场景

  • 用户重复点击(网络波动)

  • MQ消息重复

  • 应用使用失败或超时重试机制

接口幂等

基于RESTful API的角度对部分常见类型请求的幂等性特点进行分析

解决

token+redis

创建商品、提交订单、转账、支付等操作

比如下图这个下单的操作

左图是商品的详情,只有点击了立即购买预估才会跳转到右图的下单页面,这两个图就对应了两次请求。

第一个请求也就是查看详情的这个请求,首先会去获取token。比如打开这个页面的时候就会往后端发起一个请求,生成唯一token,为了保证token的唯一性,可以生成一个uuid来作为token,然后存储到redis,存入redis的key是当前登录的用户,value就是这个生成的token,同时把这个token返回给前端。

第二个阶段,用户点击了立即购买预估,就会跳转到待提交订单的页面。当用户点击提交订单的时候,就需要把上面返回的token携带上去访问服务端,然后服务端需要到redis中去查询这个token是否存在,如果存在的话,就直接处理业务即可,同时在redis中把这个token删除即可,不存在直接返回。

比如说现在用户点击了多次请求,因为我们第一次请求的时候我们验证已经存在了,然后在redis中把这个token删除掉了,第二次或第三次请求就没有办法再去查询到redis中的这个token了,所以说这些请求就会请求失败。

分布式锁

分布式锁用起来也是比较简单,其实就是加锁处理即可。用到的就是redisson提供的一个分布式锁,首先先拿到一个锁,然后判断是否获取锁成功,如果获取锁失败了,直接快速给用户响应失败信息即可。如果拿到锁了,就正常处理下单操作即可,最后一定要手动把锁给释放了,把锁的粒度尽量的保持在最小的范围(因为加上分布式锁之后,其它线程可能就会处于等待或阻塞,性能肯定是不太好的)

总结:

分布式服务的接口幂等性如何设计?
  • 幂等:多次调用方法或者接口不会改变业务状态,可以保证重复调用的结果和单次调用的结果一致

  • 如果是新增数据,可以使用数据库的唯一索引

  • 如果是新增或修改数据

    • 分布式锁,性能较低

    • 使用token+ redis来实现,性能较好

      • 第一次请求,生成一个唯一token存入redis,返回给前端

      • 第二次请求,业务处理,携带之前的token,到redis进行验证,如果存在,可以执行业务,删除token;如果不存在,则直接返回,不处理业务

参考回答:

5、你们项目中使用了什么分布式任务调度

xxl-job(目前比较主流的)

首先,还是要描述当时是什么场景用了任务调度

Xx-job解决的向题

  • 解决集群任务的重复执行问题

  • cron表达式定义灵活

  • 定时任务失败了,重试和统计

  • 任务量大,分片执行

xxl-job常见面试题

  1. xxl-job路由策略有哪些?

  2. xxl-job任务执行失败怎么解决?

  3. 如果有大数据量的任务同时都需要执行,怎么解决?

xxl-job路由策略有哪些?

1.FIRST(第一个):固定选择第一个机器;

2.LAST(最后一个):固定选择最后一个机器;

3.ROUND (轮询)

4.RANDOM(随机):随机选择在线的机器:

5.CONSISTENT_HASH(一致性HASH):每个任务按照Hash算法固定选择某一台机器,且所有任务均匀散列在不同机器上。

6.LEAST._ FREQUENTLY_USED(最不经常使用):使用频率最低的机器优先被选举;

7.LEAST_RECENTLY_USED (最近最久未使用):最久未使用的机器优先被选举;

8.FAILOVER(故障转移):按照顺序依次进行心跳检测,第一个心跳检测成功的机器选定为目标执行器并发起调度;

9.BUSYOVER(忙碌转移):按照顺序依次进行空闲检测,第一个空闲检测成功的机器选定为目标执行器并发起调度;

10.SHARDING_ BROADCAST(分片广播):广播触发对应集群中所有机器执行一次任务,同时系统自动传递分片参数;可根据分片参数开发分片任务;

xxl-job任务执行失败怎么解决?

故障转移+失败重试,查看日志分析----> 邮件告警

如果有大数据量的任务同时都需要执行,怎么解决?

执行器集群部署时,任务路由策略选择分片广播情况下,一次任务调度将会广播触发对应集群中所有执行器执行一次任务

如图,比如现在有很多的任务,现在我们是部署了多台机器同时去执行这些任务,怎么去执行呢?这里面的关键点就是一次任务去执行多次任务项,这时候我们是去找集群下的所有机器共同去执行。

比如说现在有三台机器,那我们的路由策略必须要选择分片广播才行。在任务执行的时候是这样的,首先是按照取模的方式,任务项去模上总分片数,比如目前分片数是3,任务项去模于3就可以找到对应的机器去执行。

总结:

xxl-job路由策略有哪些?
  • xxl-job提供了很多的路由策略,我们平时用的较多就是:轮询、故障转移、分片广播…

xxl-job任务执行失败怎么解决?
  • 路由策略选择故障转移,使用健康的实例来执行任务

  • 设置重试次数

  • 查看日志+邮件告警来通知相关负责人解决

如果有大数据量的任务同时都需要执行,怎么解决?
  • 让多个实例一块去执行(部署集群),路由策略分片广播

  • 在任务执行的代码中可以获取分片总数和当前分片,按照取模的方式分摊到各个实例执行

参考回答:

三、消息中间件

一 RabbitMQ

1、RabbitMQ-如何保证消息不丢失

  • 异步发送(验证码、短信、邮件...)

  • MYSQL和Redis,ES之间的数据同步

  • 分布式事务

  • 削峰填谷

  • ...

生产者生产消息,没有到达交换机,或者没有到达队列、 MQ宕机、消费者服务宕机,这些情况都会有丢失消息的情况,所以,要想解决消息丢失的问题,我们也是要从三个层面去解决。

生产者确认机制

RabbitMQ提供了publisher confirm机制来避免消息发送到MQ过程中丢失。消息发送到MQ以后,会返回一个结果给发送者,表示消息是否处理成功

当消息正常发送到队列之后,会返回一个publish-confirm ack,告诉生产者已经收到消息了。

如果消息发送失败,这个失败有两种情况

  • 1.一个是消息到达交换机失败,会返回 publish-confirm nack

  • 2.另一个是交换机路由到队列失败,返回 publish-return ack

消息失败之后如何处理呢?

  • 回调方法即时重发

  • 记录日志

  • 保存到数据库然后定时重发,成功发送后即刻删除表中的数据

消息持久化

这种情况是生产者已经正常发送到了队列中,但是MQ宕机了,这种情况也有可能会导致消息的丢失,解决方案就是,交换机、队列、消息都要做持久化。

MQ默认是内存存储消息,开启持久化功能可以确保缓存在MQ中的消息不丢失。

1、交换机持久化:

2、队列持久化

3、消息持久化,SpringAMQP中的消息默认是持久的,可以通过MessageProperties中的DeliveryMode来指定

消费者确认

RabbitMQ支持消费者确认机制,即:消费者处理消息后可以向MQ发送ack回执,MQ收到ack回执后才会删除该消息。而SpringAMQP则允许配置三种确认模式:

  • manual:手动ack,需要在业务代码结束后,调用api发送ack。

  • auto:自动ack,由spring监测listener代码是否出现异常,没有异常则返回ack;抛出异常则返回nack

  • none:关闭ack,MQ假定消费者获取消息后会成功处理,因此消息投递后立即被删除

我们可以利用Spring的retry机制,在消费者出现异常时利用本地重试,设置重试次数,当次数达到了以后,如果消息依然失败,将消息投递到异常交换机,交由人工处理

总结就是,消费者这块,确认消息要消费,发送一个ack,一般使用auto自动确认机制。第二个我们会设置一个重试机制,这个重试并不是无限次的,会设置一个重试的次数,如果达到了这个次数之后,会把消息投递到一个异常的交换机中让人工去处理。

总结:
RabbitMQ-如何保证消息不丢失
  • 开启生产者确认机制,确保生产者的消息能到达队列

  • 开启持久化功能,确保消息未消费前在队列中不会丢失

  • 开启消费者确认机制为auto,由spring确认消息处理成功后完成ack

  • 开启消费者失败重试机制,多次重试失败后将消息投递到异常交换机,交由人工处理

参考回答:

2、RabbitMQ消息的重复消费问题如何解决的

什么情况下会导致消息的重复消息问题

  • 网络抖动

  • 消费者挂了

像上面说的,消费者这块会有一个自动的确认机制来通知MQ说消费者已经正常消费消息了。假如现在消费者已经正常处理完了消息,还没来得及给MQ发送确认,这时候网络发生了抖动,或者是消费者挂了,那等网络恢复之后,或者是消费者重启之后,因为之前MQ没有收到确认,这个消息还在MQ中,并且我们设置了重试机制,那消费者就会重新的消费这个消息,这就重复消息了。

解决方案:

  • 每条消息设置一个唯一的标识id

    • 比如生产者发送消息的时候,有一个业务唯一标识id,消费者这块接收到消息的时候,先校验业务id是否存在,如根据订单id去表中查询,如果不存在,我们就正常接收消息处理业务就行了,如果id存在了,就证明这个消息已经消费过了,就不需要再消费了。

  • 幂等方案:【分布式锁:数据库锁(悲观锁、乐观锁)】

    • 加锁肯定会导致性能的下降的,如果业务中包含了唯一标识,建议优先采用第一种方案

适用于任何MQ:

  • Kafka

  • RabbitMQ

  • RocketMQ

  • ...

参考回答:

3、RabbitMQ中死信交换机?(RabbitMQ延迟队列有了解过嘛)

  • 延迟队列:进入队列的消息会被延迟消费的队列

  • 场景:超时订单、限时优惠、定时发布

死信交换机

当一个队列中的消息满足下列情况之一时,可以成为死信 (dead letter):

  • 消费者使用basic.reject或 basic.nack声明消费失败,并且消息的requeue参数设置为false

  • 消息是一个过期消息,超时无人消费

  • 要投递的队列消息堆积满了,最早的消息可能成为死信

如果该队列配置了dead-letter-exchange属性,指定了一个交换机,那么队列中的死信就会投递到这个交换机中,而这个交换机称为死信交换机(Dead Letter Exchange, 简称DLX)。

TTL

TTL,地就是Time-To-Live。如果一个队列中的消息TTL结束仍未消费,则会变为死信,ttl 超时分为两种情况:

  • 消息所在的队列设置了存活时间

  • 消息本身设置了存活时间

1.像发消息的时候可以指定一个ttl,比如5s的存活时间, 发送消息直接到了队列中。假如现在已经过了5s了,这个消息依然没有被消费,那就会成为死信。死信就会到死信交换机中,然后是由绑定了死信交换机的死信队列去消费这个消息

2.在声明队列的时候也可以声明一个ttl,消息的存活时间。如图在队列中设置了10000,就是10s的存活时间,在发送消息的时候设置的是5000。这里是以哪个存活时间短就以哪个为准的,如果队列设置的存活时间是1000,就以队列的这个1000的存活时间为准

延迟队列插件

DelayExchange插件,需要安装在RabbitMQ中

RabbitMQ有一个官方的插件社区,地址为:https//www.rabbitmq.com/community-plugins.html

DelayExchange的本质还是官方的三种交换机,只是添加了延迟功能。因此使用时只需要声明一个交换机,交换机的类型可以是任意类型,然后设定delayed属性为true即可。

总结:
RabbitMQ中死信交换机?(RabbitMQ延迟队列有了解过嘛)
  • 我们当时一个什么业务使用到了延迟队列(超时订单、限时优惠、定时发布...)

  • 其中延迟队列就用到了死信交换机和TTL(消息存活时间)实现的

  • 消息超时未消费就会变成死信(死信的其他情况:拒绝被消费,队列满了)

延迟队列插件实现延迟队列DelayExchange

  • 声明一个交换机,添加delayed属性为true

  • 发送消息时,添加x-delay头,值为超时时间

参考回答:

4、RabbitMQ如果有100万消息堆积在MQ,如何解决(消息堆积怎么解决)

当生产者发送消息的速度超过了消费者处理消息的速度,就会导致队列中的消息堆积,直到队列存储消息达到上限。之后发送的消息就会成为死信,可能会被丢弃,这就是消息堆积问题

解决消息堆积有三种种思路:

  • 增加更多消费者,提高消费速度

  • 在消费者内开启线程池加快消息处理速度

  • 扩大队列容积,提高堆积上限

惰性队列

惰性队列的特征如下

  • 接收到消息后直接存入磁盘而非内存

  • 消费者要消费消息时才会从磁盘中读取并加载到内存

  • 支持数百万条的消息存储

定义惰性队列,可以使用配置或者注解的方式都可以,定义了惰性队列之后,消息就不会存储在内存了,就会存储在磁盘,存储的量也就很大了。

1、配置的方式

2、注解的方式

总结:
RabbitMQ如果有100万消息堆积在MQ,如何解决(消息堆积怎么解决)

解决消息堆积有三种种思路:

  • 增加更多消费者,提高消费速度

  • 在消费者内开启线程池加快消息处理速度

  • 扩大队列容积,提高堆积上限,采用惰性队列

    • 在声明队列的时候可以设置属性x-queue-mode为lazy,即为惰性队列

    • 基于磁盘存储,消息上限高

    • 性能比较稳定,但基于磁盘存储,受限于磁盘IO,时效性会降低

参考回答:

5、RabbitMQ的高可用机制有了解过嘛

  • 在生产环境下,使用集群来保证高可用性

  • 普通集群、镜像集群、仲裁队列

普通集群

普通集群,或者叫标准集群(classic cluster),具备下列特征:

  • 会在集群的各个节点间共享部分数据,包括:交换机、队列元信息。不包含队列中的消息。

  • 当访问集群某节点时,如果队列不在该节点,会从数据所在节点传递到当前节点并返回

  • 队列所在节点宕机,队列中的消息就会丢失

如图,有三个节点的集群,现在有一个交换机test.exchange,这个交换机的信息是在各个节点上都有的,如果这时候还有队列的话,那每个节点上的队列是不一样的,不过其它节点,是可以有队列的引用信息的。比如在第一个节点中有一个test.queue1,在其它节点中,是存在test.queue1的引用信息的,这些引用信息也叫做元信息。但是,这些引用信息,并不包含队列中的消息。

假如,当访问集群某节点时,如果队列不在该节点,会从数据所在节点传递到当前节点并返回。比如现在有一个消费者,它要去消费test.queue1队列中的消息,但是,它找的是第三个节点,这时候因为第三个节点当中有test.queue1队列的引用,所以说它会传递到test.queue1去消费消息,同理访问第二个节点也是一样的。

这种集群有一个比较大的缺点就是,所在节点宕机,队列中的消息就丢失了,所以在项目中一般很少使用这个普通的集群。

镜像集群

镜像集群:本质是主从模式,具备下面的特征:

  • 交换机、队列、队列中的消息会在各个mq的镜像节点之间同步备份。

  • 创建队列的节点被称为该队列的主节点,备份到的其它节点叫做该队列的镜像节点。

  • 一个队列的主节点可能是另一个队列的镜像节点

  • 所有操作都是主节点完成,然后同步给镜像节点

  • 主宕机后,镜像节点会替代成新的主

如图,有三个节点,节点中的交换机信息还是共享的,每个节点都存在。

节点1中有一个test.queue1队列,节点2中也有一个test.queue1队列,但是它们现在是主从关系,数据是同步的,都保存了队列和队列中的消息,其中创建队列的节点1称为主节点,现在备份到了节点2上,节点2就是镜像节点。

当然,一个队列的主节点也可能是另一个队列的镜像节点。比方说每个节点都有自己的队列,相对来说,节点3创建的test.queue3队列是主节点,那节点1所创建的test.queue3,它是一个镜像节点。

所有操作都是主节点完成,然后同步给镜像节点。如果主节点宕机后,镜像节点会替代成新的主节点,成为新的主节点。

但是这里依然会存在一个小问题,比如节点1中的队列,更新信息之后,还没来得及给镜像节点同步数据,这时候主节点宕机了,这样数据就可能还没有同步完,就会丢失数据。不过这种情况还是比较少见的,非要解决的话,可以使用RabbitMQ提供的第三种集群模式,仲裁队列。

仲裁队列

仲裁队列:仲裁队列是3.8版本以后才有的新功能,用来替代镜像队列,具备下列特征:

  • 与镜像队列一样,都是主从模式,支持主从数据同步

  • 使用非常简单,没有复杂的配置

  • 主从同步基于Raft办议,强一致

使用也是非常简单,在声明队列的时候,加一个参数quorum() 即可

总结:
RabbitMQ的高可用机制有了解过嘛
  • 在生产环境下,我们当时采用的镜像模式搭建的集群,共有3个节点

  • 镜像队列结构是上主多从(从就是镜像),所有操作都是主节点完成,然后同步给镜像节点

  • 主宕机后,镜像节点会替代,成新的主(如果在主从同步完成前,主就已经宕机,可能出现数据丢失)

那出现丢数据怎么解决呢?

我们可以采用仲裁队列,与镜像队列一样,都是主从模式,支持主从数据同步,主从同步基于Raft协议,强一致。

并且使用起来也非常简单,不需要额外的配置,在声明队列的时候只要指定这个是仲裁队列即可

参考回答:

二 Kafka

1、Kafka是如何保证消息不丢失的

使用Kafka在消息的收发过程都会出现消息丢失,Kafka分别给出了解决方案

  • 生产者发送消息到Brocker丢失

  • 消息在Brocker中存储丢失

  • 消费者从Brocker接收消息丢失

生产者发送消息到Broker丢失
  • 设置异步发送

  • 消息重试

消息在Brocker中存储丢失

  • 发送确认机制acks

消费者从Brocker接收消息丢失

总结:
Kafka是如何保证消息不丢失

需要从三个层面去解决这个问题:

  • 生产者发送消息到Brocker丢失

    • 设置异步发送,发送失败使用回调进行记录或重发

    • 失败重试,参数配置,可以设置重试次数

  • 消息在Brocker中存储丢失

    • 发送确认acks,选择all,让所有的副本都参与保存数据后确认

  • 消费者从Brocker接收消息丢失

    • 关闭自动提交偏移量,开启手动提交偏移量

    • 提交方式,最好是同步+异步提交

Kafka中消息的重复消费问题如何解决的
  • 关闭自动提交偏移量,开启手动提交偏移量

  • 提交方式,最好是同步+异步提交

  • 幂等方案

参考回答:

2、Kafka是如何保证消费的顺序性

应用场景:

  • 即时消息中的单对单聊天和群聊,保证发送方消息发送顺序与接收方的顺序一致

  • 充值转账两个渠道在同一个时间进行余额变更,短信通知必须要有顺序

消费者从Brocker接收消息丢失

topic分区中消息只能由消费者组中的唯——个消费者处理,所以消息肯定是按照先后顺序进行处理的。但是它也仅仅是保证Topic的一个分区顺序处理,不能保证跨分区的消息先后处理顺序。所以,如果你想要顺序的处理Topic的所有消息,那就只提供一个分区。

总结:
Kafka是如何保证消费的顺序性

问题原因:

—个topic的数据可能存储在不同的分区中,每个分区都有一个按照顺序的存储的偏移量,如果消费者关联了多个分区不能保证顺序性

解决方案:

  • 发送消息时指定分区号

  • 发送消息时按照相同的业务设置相同的key

参考回答:

3、Kafka的高可用机制有了解过嘛

  • 集群模式

  • 分区备份机制

集群模式

  • Kafka 的服务器端由被称为 Broker 的服务进程构成,即一个 Kafka 集群由多个 Broker 组成

  • 这样如果集群中某一台机器宕机,其他机器上的 Broker 也依然能够对外提供服务。这其实就是Kafka 提供高可用的手段之一

分区备份机制

  • 一个topic有多个分区,每个分区有多个副本,其中有一个leader,其余的是follower,副本存储在不同的broker中

  • 所有的分区副本的内容是都是相同的,如果leader发生故障时,会自动将其中一个follower提升为leader

ISR (in-sync replica) 需要同步复制保存的follower

如果leader失效后,需要选出新的leader,选举的原则如下:

  • 第一:选举时优先从ISR中选定,因为这个列表中follower的数据是与leader同步的

  • 第二:如果ISR列表中的follower都不行了,就只能从其他follower中选取

ISR配置

总结:
Kafka的高可用机制有了解过嘛

可以从两个层面回答,第一个是集群,第二个是复制机制

集群:

—个kafka集群由多个broker实例组成,即使某一台宕机,也不耽误其他broker继续对外提供服务

复制机制:

  • 一个topic有多个分区,每个分区有多个副本,有—个leader,其余的是follower,副本存储在不同的broker中

  • 所有的分区副本的内容是都是相同的,如果leader发生故障时,会自动将其中一个follower提升为leader,保证了系统的容错性、高可用性

解释一下复制机制中的ISR

ISR (in-sync replica)需要同步复制保存的follower

分区副本分为了两类,一个是ISR,与leader副本同步保存数据,另外一个普通的副本,是异步同步数据,当leader挂掉之后,会优先从ISR副本列表中选取一个作为leader

参考回答:

4、Kafka数据清理机制了解过嘛

  • Kafka文件存储机制

  • 数据清理机制

Kafka文件存储机制

存储结构:

如图,生产者还是通过topic发送数据,topic是一个逻辑概念,真正存储数据的位置是分区。现在往itheima这个topic中发送消息,假如这个topic对应了三个分区,它的命名是这样的:

前面是topic的名称,后面是分区号,比如itheima-0。在真正数据存储的时候,分别对应了三个文件夹,命名也是这三个

然后在分区内部就存储了数据文件,但是这个文件也是分段存储的。比如在一个分区下,可能会存在多个日志文件段,也叫做Segment,没个段也对应了三个文件,分别是index 索引文件,log数据文件,timeindex 时间索引文件。看下面这个图,上面三个是一个段,下面三个是另外一个段

为什么要分段呢?

  • 删除无用文件方便,提高磁盘利用率

    • 比如像一些比较久的数据文件,已经被消费过很长时间了,这些文件已经没用了,这些没用的文件分段之后会更加方便的进行删除。 如果我们不分段,所有数据都放在同一个文件里面,那删除文件就会相当麻烦,在一个文件里面筛选,效率肯定是不高的。

  • 查找数据便捷

    • 假如现在的消息量特别大,如果都在同一个文件里,那它的速度肯定是会受限的。那分段之后呢,可以看一下这个数据文件的命名,都是以偏移量进行命名的,index也是一样的,它们两个是成对出现的,以后在查找数据的时候,肯定是知道了具体的偏移量的,然后我们就可以快速的定位某一个文件,通过index索引文件,再去到log文件中找出具体的数据。

数据清理机制

日志的清理策略有两个

1、根据消息的保留时间,当消息在kafka中保存的时间超过了指定的时间,就会触发清理过程

2、根据topic存储的数据大小当topic所占的日志文件大小大于一定的國值,则开始删除最久的消息。需手动开启

总结:
Kafka数据清理机制了解过嘛

Kafka存储结构

  • Kafka中topic的数据存储在分区上,分区如果文件过大会分段存储segment

  • 每个分段都在磁盘上以索引I(xxxx.index)和日志文件(xxxx.log)的形式存储

  • 分段的好处是,第一能够减少单个文件内容的大小,查找数据方便,第二方便kafka进行日志清理。

日志的清理策略有两个:

  • 根据消息的保留时间,当消息保存的时间超过了指定的时间,就会触发清理,默认是168小时(7天)

  • 根据topic存储的数据大小,当topic所占的日志文件大小大于一定的國值,则开始删除最久的消息。(默认关闭)

参考回答:

5、Kafka中实现高性能的设计有了解过嘛

  • 消息分区:不受单台服务器的限制,可以不受限的处理更多的数据

  • 顺序读写:磁盘顺序读写,提升读写效率

  • 页缓存:把磁盘中的数据缓存到內存中,把对磁盘的访问变为对内存的访问

  • 零拷贝:减少上下文切换及数据拷贝

  • 消息压缩:减少磁盘IO和网络IO

  • 分批发送:将消息打包批量发送,减少网络开销

零拷贝

如图一

在Linux系统中,主要划分了两块空间,用户空间和内核空间。

其中用户空间的权限是比较小的,内核空间的权限更大一些,它可以调用系统的一切资源。

现在我们有一个Kafka服务,首先它肯定是属于用户空间的。假如现在有一个生产者producer要去发送消息,肯定是从用户空间发起的。然后这个消息需要存储到磁盘空间上,那这个要怎么做呢?用户空间是没有权限直接调用磁盘读写的。

所以说,会先把数据拷贝到内核空间去处理,在内核空间中,一个linux的缓存,页缓存。把数据存储到了这个页缓存中,然后到了一定的批次以后,就是批量发送,就会把数据写入到磁盘上,这个就是保存消息的一个流程。

假如,现在有一个消费者consumer要去消费这个消息。首先,用户空间的Kafka,会先到页缓存中去找,看看有没有这个消息,如果没有,再到磁盘文件中去读取文件中的消息,然后把消息拷贝到页缓存中,再然后从页缓存中把数据拷贝到用户空间的Kafka中,现在要把消息发送给消费者,要怎么做呢?这时候就需要用到Socket连接和网卡了,它们就能把消息给发送出去

所以,数据又从用户空间的Kafka,拷贝到内核空间的Socket缓冲区,最后把数据拷贝到网卡,由网卡把数据传送给消费者,这个流程就结束了。

可以计算一下,这个流程数据拷贝了几次,从磁盘文件到页缓存,拷贝了第一次。从页缓存拷贝到Kafka第二次,Kafka到Socket第三次,Socket到网卡第四次。拷贝的次数太多了,可以想到,这个性能肯定是不高的。所以,Kafka内部选择了使用零拷贝。

如图二

它主要就是减少了数据拷贝的次数,流程是这样的

现在还是消费者去消费数据,首先Kafka还是会去判断页缓存中是否存在这个消息数据,如果不存在,还是会从磁盘文件中去读取数据然后把数据拷贝到页缓存中。关键就在这里,现在Kafka知道哪个消费者要去消费这个消息,所以把所有的事情都委托给了系统去操作,这时候就不需要再去拷贝了,而是由系统直接从页缓存中把数据拷贝给了网卡,然后就能找到消费者去消费。这个流程只拷贝了两次,性能自然就变高了。

总结:
Kafka中实现高性能的设计有了解过嘛
  • 消息分区:不受单台服务器的限制,可以不受限的处理更多的数据

  • 顺序读写:磁盘顺序读写,提升读写效率

  • 页缓存:把磁盘中的数据缓存到内存中,把对磁盘的访问变为对内存的访问

  • 零拷贝:减少上下文切换及数据拷贝

  • 消息压缩:减少磁盘IO和网络IO

  • 分批发送:将消息打包批量发送,减少网络开销

参考回答:


五、集合篇

Java集合框架体系

数据结构

  • 数组

  • 链表

  • 二叉树

  • 红黑树

  • 散列表

一、算法复杂度分析

为什么要进行复杂度分析?

  • 指导你编写性能更优的代码

  • 评判别人写的代码的好坏

时间复杂度

空间复杂度

时间复杂度

时间复杂度分析

时间复杂度分析:来评估代码的执行耗时的

  • 大O表示法:不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势

  • T(n) 与代码的执行次数成正比(代码行数越多,执行时间越长

  • 当n很大时,公式中的低阶,常量,系数三部分并不左右其增长趋势,因此可以忽略,我们只需要记录一个最大的量级就可以了

常见复杂度表示形式

常见复杂度

O(1)

总结:只要代码的执行时间不随着n的增大而增大,这样的代码复杂度都是O(1)

O(n)

O(n2)

O(log n)

O(n * log n)

总结:

空间复杂度

空间复杂度全称是渐进空间复杂度,表示算法占用的额外存储空间数据规模之间增长关系

O(1)

如图,这段代码的时间复杂度为O(n)。 但是空间复杂度为O(1)

因为sum是一个局部变量,在定义这个局部变量的时候,当前这个变量是int类型的,已经给了4个字节了,就算循环再怎么累加,sum的值也并不会去额外的占用空间,所以当前的空间复杂度为O(1)

O(n)

可以看到这段代码,当前数组a的占用空间有多大,是和参数n是有很大关系的,n传递的有多大,数据就要去开辟多大的空间,所以这段代码的空间复杂度就是O(n)了

我们常见的空间复杂度就是O(1),O(n),O(n^2),其他像对数阶的复杂度几乎用不到,因此空间复杂度比时间复杂度分析要简单的多。

总结:

1、什么是算法时间复杂度

时间复杂度表示了算法的执行时间数据规模之间的增长关系

2、常见的时间复杂度有哪些?

O(1)、O(n)、O(n^2)、O(log n)

速记口诀:常对幂指阶

3、什么是算法的空间复杂度?

表示算法占用的额外存储空间数据规模之间的增长关系

常见的空间复杂度:O(1),O(n),O(n^2)

二、List相关面试题

数组

数组(Array)是一种用连续的内存空间存储相同数据类型数据的线性数据结构。

数组如何获取其他元素的地址值?

为了方便计算,这里把内存地址改为整形的值

底层是通过一个寻址公式来去计算某一个下标的内存地址的

  • i:某一个索引

  • baseAddress:当前数组的首地址(比如当前这个数组的首地址是 10)

  • dataTypeSize:代表数组中元素类型的大小,int型的数据,dataTypeSize=4个字节

可以代入公式计算一下,比如要找33这个元素,baseAddress是10,索引i是1,就是10 + 1*4 = 14,因为int类型占用4个字节,就找到了14到17这4块内存。如果要找88这个元素,就是10 + 2*4 = 18,就可以找到18到21这4块内存,定位到88这个元素。

为什么数组索引从0开始呢?假如从1开始不行吗?

如图,我们把索引改为从1开始,那上面的公式肯定就不行了,我们需要用到下面这个公式。

再代入公式计算一下,比如查找33这个元素,索引i是2,就是 10 + (2-1)*4 = 14,也能定位到内存地址为14这个位置,找到33这个元素

所以,对比两个公式,不难发现,假如数组是从1开始,根据下标去访问数组元素的时候,对于CPU来说,就多了一次减法指令操作。其实上面的这个公式就是一种优化,也别小看这种优化,假如上亿次的计算,那积累起来的优势也是相当可观的。

操作数组的时间复杂度(查找)

1、随机查询(根据索引查询)

数组元素的访问是通过下标来访问的,计算机通过数组的首地址寻址公式能够很快速的找到想要访问的元素

2、未知索引查询

情况一:不排序数组,查找数组内的元素,查找55号元素

  • 最坏情况:需要遍历整个数组,找到最后一个元素才找到55号这个元素。这种情况时间复杂度为O(n)

  • 最好情况:第一次遍历的时候就能找到当前需要查找的元素了,这种情况时间复杂度为O(1)

  • 但是数组在不排序的情况下直接去找元素,它的平均时间复杂度就是O(n)

情况二:查找排序后数组内的元素,查找55号数据

  • 如果说现在还是从索引0的位置开始找一直找到5,遍历整个数组,它的时间复杂度仍然是O(n)

  • 但是现在既然排序了,就可以使用二分查找法进行查找元素

    • 比如首先可以将这个数组折半,折半就能定位到22号元素

    • 22和要查找的元素55一对比,可以判断出22左边的元素都不符合,所以就只剩下右边的三个元素

    • 再将右边的三个元素折半,就能定位到我们需要的55号元素了

  • 所以它的查找效率相对是要更高的,二分查找 时间复杂度为O(logn)

操作数组的时间复杂度(插入、删除)

数组是一段连续的内存空间,因此为了保证数组的连续性会使得数组的插入和删除的效率变的很低。

最好情況下是O(1)的,最坏情况下是O(n)的,平均情况下的时间复杂度是O(n)。

如图,现在有一个元素X想要插入到a3这个位置,那a3及后面的元素都需要往后挪,挪完之后这个元素X就可以插入到之前a3这个位置了。可以看到,那么多的数据都需要往后挪,效率肯定是不高的。删除操作也是类似的,比如现在要把X删除掉,那a3及后面的元素,都需要往前挪。它的时间复杂度为O(n)

当然也有一种特殊情况,比如说,现在想要插入一个数据到数组的最后一个元素,这样前面所有的数据就不用再挪了,它的效率是非常高的,这种情况的时间复杂度是O(1) 【尾插法】

总结:

1、数组(Array)是一种用连续的内存空间存储相同数据类型

数据的线性数据结构。

2、数组下标为什么从0开始

寻址公式是:baseAddress + i * dataTypeSize,计算下标的内存地址效率较高

3、查找的时间复杂度
  • 随机(通过下标)查询的时间复杂度是O(1)

  • 查找元素(未知下标)的时间复杂度是O(n)

  • 查找元素(未知下标但排序)通过二分查找的时间复杂度是O(logn)

4、插入和删除时间复杂度

插入和删除的时候,为了保证数组的内存连续性,需要挪动数组元素,平均

时间复杂度为O(n)

ArrayList源码分析

ArrayList源码分析-成员变量

打开ArrayList这个类,可以看到以上的成员变量

1、private static final int DEFAULT_CAPACITY = 10;

  • 下面这个elementData这个Object数组的初始容量为10。

2、private static final int DEFAULT_CAPACITY = 10; 与 private static final Object[] EMPTY_ELEMENTDATA = {};

  • 它两代表的含义都是一样的,代表空数组,但是名字不太一样,还有使用场景也不太一样。下面会有具体的使用场景

3、transient Object[] elementData;

这个elementData是一个Object数组,ArrayList底层使用的就是数组实现的,真正存储数据的地方就是这个数组了。下面分析源码的时候会不断操作这个数组

4、private int size;

  • 最后一个属性是 size,它代表的是集合中有多少个元素,即元素的个数。我们操作数据的时候就要同时操作当前这个变量,比如添加一个元素就是加1,删除一个元素就是减1的操作。

ArrayList源码分析-构造方法

一个三个构造函数,先看前两个构造函数

1、带初始化容量的构造函数

  • 如果给定容量是>0的,则创建一个给定容量的Object数组,最终是赋值给了elementData。

  • 如果给定容量==0。则创建一个空的数组,赋值给elementData,看源码成员变量,EMPTY_ELEMENTDATA这个属性就是一个空的集合。

  • 如果以上两个条件都不满足,也就是<0,直接抛出异常。

2、无参构造函数

  • 这里创建的也是一个空数组,但是是一个默认容量的elementData,注意这虽然是有默认容量,但是是一个空集合,目前还没有真正的容量,在下面添加数据逻辑的时候会有说明。

再来看第三个构造函数

  • public ArrayList(Collection<? extends E> c) {

    • 参数接收了一个Collection对象,Collection是所有单列集合的父接口。

  • Object[] a = c.toArray();

    • Collection转成了一个Object数组

    • 数组长度如果=0,直接走else逻辑,创建一个空数组。

    • 数组长度不等于0,首先判断这个类型是否是ArrayList,如果是的话,直接将赋值给elementData。如果不是的话,调用Arrays.copyOf拷贝数组的方法,把它所有的数据都拷贝到elementData数组中。

  • 整体作用:将collection对象转换成数组,然后将数组的地址的

    赋给elementData

ArrayList源码分析-添加和扩容操作(第1次添加数据)

ArrayList源码分析-添加和扩容操作(第2至10次添加数据)

ArrayList源码分析-添加和扩容操作(第11次添加数据)

ArrayList底层的实现原理是什么

  • ArrayList底层是用动态的数组实现的

  • ArrayList初始容量为0,当第一次添加数据的时候才会初始化容量为10

  • ArrayList在进行扩容的时候是原来容量的1.5倍,每次扩容都需要拷贝数组

  • ArrayList在添加数据的时候

    • 确保数组已使用长度(size)加1之后足够存下下一个数据

    • 计算数组的容量,如果当前数组己使用长度+1后的大于当前的数组长度,则调用grow方法扩容(原来的1.5倍)

    • 确保新增的数据有地方存储之后,则将新元素添加到位于size的位置上。

    • 返回添加成功布尔值。

ArrayList list = new ArrayList(10) 中的list扩容几次

参考回答:

该语句只是声明和实例了一个 ArrayList,指定了容量为10,未扩容

如何实现数组和List之间的转换

参考回答:

  • 数组转List,使用JDK中java.util.Arrays工具类的asList方法

  • List转数组,使用List的toArray方法。无参toArray方法返回Object数组,传入初始化长度的数组对象,返回该对象数组

注意:asList方法转成的集合不可变, 只可读。

面试官再问:

  • 用Arrays.asList转List后,如果修改了数组内容,list受影响吗

  • List用toArray转数组后,如果修改了List内容,数组受影响吗

再答:

  • Arrays.asList转换list之后,如果修改了数组的内容,list会受影响,因为它的底层使用的Arrays类中的一个内部类ArrayList来构造的集合,在这个集合的构造器中,把我们传入的这个集合进行了包装而已,最终指向的都是同一个内存地址

  • list用了toArray转数组后,如果修改了list内容,数组不会影响,当调用了toArray以后,在底层是它是进行了数组的拷贝,跟原来的元素就没啥关系了,所以即使list修改了以后,数组也不受影响

1、可以看到asList的方法,首先数组是通过T... a这个参数传递进来的,然后在内部new了一个ArrayList,但是这个ArrayList并不是之前上面的ArrayList,而是Arrays当前这个类中的一个静态内部类,它调用的是内部类中的一个构造方法,然后传递进来的数组a就传给了构造方法的array参数,就执行了 a = Objects.requireNonNull(array); 方法,这行代码就是判断当前的array数组是否为空,如果为空直接报错了,不为空的话直接赋值给 a 变量,a就是定义的这个数组。

所以这里面只涉及到了对象的引用,并没有创建新的对象,它们指向的是同一个地址。

所以看上面的代码,一旦定义的

这个数组改了之后,下面的集合也会收影响。

ArrayList 和 LinkedList 的区别是什么?

链表

单向链表
  • 链表中的每一个元素称之为结点(Node)

  • 物理存储单元上,非连续、非顺序的存储结构

  • 单向链表:每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。记录下个结点地址的指针叫作后继指针 next

Java代码实现

private static class Node<E> {
	E item;
	Node<E> next;

	Node(E element, Node<E> next) {
		this.item = element;
		this.next = next;
	}
}

消息盒子

# 暂无消息 #

只显示最新10条未读和已读信息

备案号:粤ICP备2024164527号-1