欢迎关注公众号(通过文章导读关注:【11来了】),及时收到 AI 前沿项目工具及新技术的推送!
在我后台回复 「资料」 可领取
编程高频电子书!
在我后台回复「面试」可领取硬核面试笔记!文章导读地址:点击查看文章导读!
感谢你的关注!
前言:
🚀 春招季即将来临,你准备好迎接挑战了吗? 🌟
🎯 【30天面试冲刺计划】 —— 专为大厂面试量身定制!
🔥 跟随学习,一起解锁面试新高度! 🔥
文章目录
滴滴后端二面:starter设计、RocketMQ延迟消息、MySQL 的查询优化
题目分析
1、如果需要做一个 starter,你会怎么去考虑、设计?
这面试官应该是想问设计 SpringBoot starter,要怎么去设计呢
这里先说一些 SpringBoot starter 是什么,这个 starter 就相当于是一个工具箱,封装一些比较通用的工具,比如果限流,基本上在所有项目中都比较常用一些,因此可以将 限流 封装成为一个 starter,以后在使用的时候,可以通过引入 starter 直接使用限流的功能,不需要再重复编写一套限流逻辑,这就是 starter 的作用
这里就以限流场景为例,说一下限流 starter 中间件的设计
先说一下限流中间件的 需求背景,在正常情况下,我们的系统访问量会维持在一个比较平稳的状态,如果有推广活动,可以通过提前报备,研发人员进行对应的扩容,而为了应对一些恶意攻击,导致系统访问量剧增的情况,我们需要一套限流机制来保证系统的平稳运行,因此,可以开发一个限流的 starter 中间件,在系统中引入,来进行一系列限流操作
接下来设计限流中间件的实现,采用 AOP + RateLimiter 来实现限流组件
首先要先自定义注解,包含两个属性值:每秒允许的请求量、访问失败时返回的结果
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface DoRateLimiter {
double permitsPerSecond() default 0D;
String errorResult() default "";
}
接下来说一下切面的实现,在切面中最后调用我们写的限流服务进行处理
@Component
@Aspect
public class DoRateLimiterPoint {
// 该切面匹配了所有带有 @DoRateLimiter 注解的方法
@Pointcut("@annotation(com.zqy.ratelimiter.annotation.DoRateLimiter)")
public void aopPoint() {}
// aopPoint() && @annotation(doRateLimiter) 这样处理,可以通过方法入参就直接拿到注解,比较方便
@Around("aopPoint() && @annotation(doRateLimiter)")
public Object doRouter(ProceedingJoinPoint jp, DoRateLimiter doRateLimiter) throws Throwable {
System.out.println("进入了切面");
IRateLimiterOpService rateLimiterOpService = new RateLimiterOpServiceImpl();
return rateLimiterOpService.access(jp, getMethod(jp), doRateLimiter, jp.getArgs());
}
private Method getMethod(JoinPoint jp) throws NoSuchMethodException {
Signature sig = jp.getSignature();
MethodSignature methodSignature = (MethodSignature) sig;
return jp.getTarget().getClass().getMethod(methodSignature.getName(), methodSignature.getParameterTypes());
}
}
接下来写一下限流服务的处理
public class RateLimiterOpServiceImpl implements IRateLimiterOpService{
@Override
public Object access(ProceedingJoinPoint jp, Method method, DoRateLimiter doRateLimiter, Object[] args) throws Throwable {
// 如果注解没有限流,则执行方法
if (0 == doRateLimiter.permitsPerSecond()) return jp.proceed();
String clzzName = jp.getTarget().getClass().getName();
String methodName = method.getName();
String key = clzzName + ":" + methodName;
// 这里用 Map 缓存一下限流器,每个方法创建一个限流器缓存
if (null == Constants.rateLimiterMap.get(key)) {
// 如果该方法没有限流器的话,就创建一个
Constants.rateLimiterMap.put(key, RateLimiter.create(doRateLimiter.permitsPerSecond()));
}
RateLimiter rateLimiter = Constants.rateLimiterMap.get(key);
// 如果没有达到限流器上限
if (rateLimiter.tryAcquire()) {
return jp.proceed();
}
// 将错误信息返回
return JSONObject.parseObject(doRateLimiter.errorResult());
}
}
如何使用限流器呢?
@RestController
public class HelloController {
@DoRateLimiter(permitsPerSecond = 1, errorResult = "{\"code\": \"1001\",\"info\": \"调用方法超过最大次数,限流返回!\"}")
@GetMapping("/hello")
public Object hello() {
return "hello";
}
}
上边限流器中核心的代码的写了出来,面试的时候肯定是不用说这么详细了,主要能说出来通过 AOP + 自定义注解实现即可
这里还有一个要注意的就是,在引入自定义的限流器中间件之后,怎么让 SpringBoot 去将切面的 Bean 给注册到 Spring 中去呢?
这里在 starter 中,还需要创建一个 /META-INF/spring.factories 文件,SpringBoot 项目在启动时会读取 spring.factories 文件,读取自动配置类,并将自动配置类给注册到 Spring 中
org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.zqy.ratelimiter.config.RateLimiterAutoConfig
SpringBoot 启动时,就会将上边的 RateLimiterAutoConfig 给加载到 Spring 中去,而这个 RateLimiterAutoConfig 中就定义了切面的注册,将切面给注册到 Spring 中去,就可以生效了
@Configuration
public class RateLimiterAutoConfig {
@Bean
public DoRateLimiterPoint doRateLimiterPoint() {
System.out.println("创建了切面");
return new DoRateLimiterPoint();
}
}
Gitee 代码仓库:gitee.com/qylaile/rat…
2、RocketMQ 延迟消息底层是怎么设计的
RocketMQ 的延迟消息还是比较常用的核心功能,底层原理其实很简单,设置好消息的延迟时间之后,将消息投入到延迟队列中去,ScheduleMessageService 是专门用于处理延迟任务的,当延迟时间到达之后,将去消费延迟消息队列中的消息并发送到原始 Topic 中,消费者就可以进行消费了
在这里插入图片描述
接下来详细说一下整个过程:
延迟消息的使用,通过 setDelayTimeLevel 设置延迟时间的级别,在RocketMQ 5.x 之前,只能设置固定时间的延时消息,5.x 之后,可以自定义任意时间的延时消息
这里按 5.x 之前的演示,RocketMQ 中延迟消息共有 18 个级别:1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h
// 设置定时的逻辑
// "1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h";
message.setDelayTimeLevel(2);
当 Broker 收到生产者发送的延迟消息之后,并不会直接发送到指定的 Topic 中去,而是先进入到指定的延迟 Topic 中去
public static void transformDelayLevelMessage(BrokerController brokerController, MessageExtBrokerInner msg) {
if (msg.getDelayTimeLevel() > brokerController.getScheduleMessageService().getMaxDelayLevel()) {
msg.setDelayTimeLevel(brokerController.getScheduleMessageService().getMaxDelayLevel());
}
// Backup real topic, queueId
MessageAccessor.putProperty(msg, MessageConst.PROPERTY_REAL_TOPIC, msg.getTopic());
MessageAccessor.putProperty(msg, MessageConst.PROPERTY_REAL_QUEUE_ID, String.valueOf(msg.getQueueId()));
msg.setPropertiesString(MessageDecoder.messageProperties2String(msg.getProperties()));
// 设置延迟消息的 Topic 为:SCHEDULE_TOPIC_XXXX
msg.setTopic(TopicValidator.RMQ_SYS_SCHEDULE_TOPIC);
// 根据延迟时间的级别设置对应的 QueueId
msg.setQueueId(ScheduleMessageService.delayLevel2QueueId(msg.getDelayTimeLevel()));
}
之后,由 ScheduleMessageService 来处理,ScheduleMessageService 是 RocketMQ 中专门用于处理延迟任务的组件,在它的 start 方法中,
public class ScheduleMessageService extends ConfigManager {
public void start() {
if (started.compareAndSet(false, true)) {
// 加载 this.delayLevelTable 的数据(key: 延迟级别,value: 延迟时间 ms)
this.load();
// 线程池
this.deliverExecutorService = new ScheduledThreadPoolExecutor(this.maxDelayLevel, new ThreadFactoryImpl("ScheduleMessageTimerThread_"));
for (Map.Entry<Integer, Long> entry : this.delayLevelTable.entrySet()) {
Integer level = entry.getKey();
Long timeDelay = entry.getValue();
Long offset = this.offsetTable.get(level);
if (null == offset) {
offset = 0L;
}
if (timeDelay != null) {
// 在这提交了一个任务,用于转发延迟任务
this.deliverExecutorService.schedule(new DeliverDelayedMessageTimerTask(level, offset), FIRST_DELAY_TIME, TimeUnit.MILLISECONDS);
}
}
}
}
}
上边将 DeliverDelayedMessageTimerTask 任务提交给了线程池,核心方法就在 DeliverDelayedMessageTimerTask 的 run 方法中,run 方法调用了 executeOnTimeUp() 方法,在这里边就会将延迟任务给取出来,并发送到原始的 Topic 队列中去,消费者就可以消费延迟任务了
class DeliverDelayedMessageTimerTask implements Runnable {
@Override
public void run() {
// 核心方法
this.executeOnTimeUp();
}
}
3、那 ScheduleMessageService 怎么拉取延时消息的?
上边说了延迟消息被转发到延迟队列中去,通过 ScheduleMessageService 去拉取,这里还是先将 ScheduleMessageService 的 start 方法贴出来:
- 先通过 load 加载延迟级别对应的延迟时间的数据
- 创建有 18 个核心线程的线程池
- for 循环遍历 delayLevelTable,创建 18 个任务对每个延迟级别的任务进行处理,具体处理逻辑在 DeliverDelayedMessageTimerTask(线程) 中
public class ScheduleMessageService extends ConfigManager {
public void start() {
if (started.compareAndSet(false, true)) {
// 加载 this.delayLevelTable 的数据(key: 延迟级别,value: 延迟时间 ms)
this.load();
// 线程池
this.deliverExecutorService = new ScheduledThreadPoolExecutor(this.maxDelayLevel, new ThreadFactoryImpl("ScheduleMessageTimerThread_"));
for (Map.Entry<Integer, Long> entry : this.delayLevelTable.entrySet()) {
Integer level = entry.getKey();
Long timeDelay = entry.getValue();
Long offset = this.offsetTable.get(level);
if (null == offset) {
offset = 0L;
}
if (timeDelay != null) {
// 在这提交了一个任务,用于转发延迟任务
this.deliverExecutorService.schedule(new DeliverDelayedMessageTimerTask(level, offset), FIRST_DELAY_TIME, TimeUnit.MILLISECONDS);
}
}
}
}
}
DeliverDelayedMessageTimerTask 的 run() 中就是对延迟任务的处理,核心方法 executeOnTimeUp(),主要流程为:(这里就不列出具体的源码实现了)
- 根据 Topic 和 QueueId 找到对应的 ConsumeQueue
- 找到 ConsumeQueue 之后,根据偏移量找到消息,如果发现到达这个消息的延迟时间了,就把这个消息投递到原始的 Topic 中去,让消费者可以消费这个延迟任务
class DeliverDelayedMessageTimerTask implements Runnable {
@Override
public void run() {
// 核心方法
this.executeOnTimeUp();
}
}
4、MySQL 的查询能做哪些优化
这里面试官应该就是想要问一下 MySQL 的查询可以从哪些方面进行优化,这里先将思路写出来,再细说怎么优化
那么优化查询的话,毫无疑问就是通过索引来优化了,对表建立索引,还要让查询语句尽可能去命中索引
优化一: 让查询语句尽量走 索引 的话,主要有两个方面:
- where 语句遵循最左前缀原则
- 使用覆盖索引优化
优化二: 如果语句中,使用了 order by 的话,那么要通过 order by 和 where 的配合,让语句符合最左前缀原则,来使用索引排序(using index condition)而不是文件排序(using filesort)
下边是 8 种使用 order by 的情况,我们通过分析以下案例,可以判断出如何使用 order by 和 where 进行配合可以走using index condition(索引排序)而不是 using filesort(文件排序)
联合索引为 (name,age,position)
- case1
EXPLAIN SELECT * FROM employees WHERE name = 'LiLei' and position = 'dev' order by age;
分析:查询用到了 name 索引,从 key_len=74 也能看出,age 索引列用在排序过程中,符合最左前缀原则,使用了索引排序,因此 Extra 字段为 Using index condition
- case2
EXPLAIN SELECT * FROM employees WHERE name = 'LiLei' order by position;
分析:从 explain 执行结果来看,key_len = 74,查询使用了 name 索引,由于 order by 用了 position 进行排序,跳过了 age,不符合最左前缀原则,因此不走索引,使用了文件排序 Using filesort
- case3
EXPLAIN SELECT * FROM employees WHERE name = 'LiLei' order by age, position;
分析:查找只用到索引 name,age 和 position用于排序,与联合索引顺序一致,符合最左前缀原则,使用了索引排序,因此 Extra 字段为 Using index condition
- case4
EXPLAIN SELECT * FROM employees WHERE name = 'LiLei' order by position, age;
分析:因为索引的创建顺序为 name,age,position,但是排序的时候 age 和 position 颠倒位置了,和索引创建顺序不一致,不符合最左前缀原则,因此不走索引,使用了文件排序 Using filesort
- case5
EXPLAIN SELECT * FROM employees WHERE name = 'LiLei' and age = 18 order by position, age;
分析:与 case 4 相比,Extra 中并未出现 using filesort,并且查询使用索引 name,age,排序先根据 position 索引排序,索引使用顺序与联合索引顺序一致,因此使用了索引排序
- case6
EXPLAIN SELECT * FROM employees WHERE name = 'zqy' order by age asc, position desc;
分析:虽然排序字段列与联合索引顺序一样,但是这里的 position desc 变成了降序排序,导致与联合索引的排序方式不同,因此不走索引,使用了文件排序 Using filesort
- case7
EXPLAIN SELECT * FROM employees WHERE name in ('LiLei', 'zqy') order by age, position;
分析:先使用索引 name 拿到 LiLei,zqy 的数据,之后需要根据 age、position 排序,但是根据 name 所拿到的数据对于 age、position 两个字段来说是无序的,因此不走索引,使用了文件排序 Using filesort
为什么根据 name in 拿到的数据对于 age、position 来说是无序的:
对于下图来说,如果取出 name in (Bill, LiLei) 的数据,那么对于 age、position 字段显然不是有序的,因此肯定无法使用索引扫描排序
- case8
EXPLAIN SELECT * FROM employees WHERE name > 'a' order by name;
分析:对于上边这条 sql 来说,使用了 select *,因此 mysql 判断如果不走索引,直接使用全表扫描更快,因此不走索引,使用了文件排序 Using filesort
EXPLAIN SELECT name FROM employees WHERE name > 'a' order by name;
分析:因此可以使用覆盖索引来优化,只通过索引查询就可以查出我们需要的数据,不需要回表,通过覆盖索引优化,因此没有出现 using filesort
总结
-
MySQL支持两种方式的排序 filesort 和 index,Using index 是指 MySQL 扫描索引本身完成排序。index 效率高,filesort 效率低。
-
order by满足两种情况会使用Using index。
- order by语句使用索引最左前列。
- 使用where子句与order by子句条件列组合满足索引最左前列。
-
尽量在索引列上完成排序,遵循索引建立(索引创建的顺序)时的最左前缀法则。
-
如果order by的条件不在索引列上,就会产生Using filesort。
-
能用覆盖索引尽量用覆盖索引
-
group by 与 order by 很类似,其实质是先排序后分组,遵照索引创建顺序的最左前缀法则。对于 group by 的优化如果不需要排序的可以加上 order by null 禁止排序。注意,where 高于 having,能写在 where 中的限定条件就不要去 having 限定了。
优化三: in 和 exists 的优化,原理也就是小表驱动大表,先拿到少量符合条件的数据,再去大量数据中比较,这样效率比较高
in:当 B 表的数据集小于 A 表的数据集时,使用 in
select * from A where id in (select id from B)
exists:当 A 表的数据集小于 B 表的数据集时,使用 exists
将主查询 A 的数据放到子查询 B 中做条件验证,根据验证结果(true 或 false)来决定主查询的数据是否保留
select * from A where exists (select 1 from B where B.id = A.id)