【面试深度解析】滴滴后端二面:starter设计、RocketMQ延迟消息、MySQL 的查询优化(上)-CSDN博客

89 阅读11分钟

欢迎关注公众号(通过文章导读关注:【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 方法贴出来:

  1. 先通过 load 加载延迟级别对应的延迟时间的数据
  2. 创建有 18 个核心线程的线程池
  3. 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(),主要流程为:(这里就不列出具体的源码实现了)

  1. 根据 Topic 和 QueueId 找到对应的 ConsumeQueue
  2. 找到 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

总结

  1. MySQL支持两种方式的排序 filesort 和 index,Using index 是指 MySQL 扫描索引本身完成排序。index 效率高,filesort 效率低。

  2. order by满足两种情况会使用Using index。

    • order by语句使用索引最左前列。
    • 使用where子句与order by子句条件列组合满足索引最左前列。
  3. 尽量在索引列上完成排序,遵循索引建立(索引创建的顺序)时的最左前缀法则。

  4. 如果order by的条件不在索引列上,就会产生Using filesort。

  5. 能用覆盖索引尽量用覆盖索引

  6. 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)