14、【对线面试官】SpringMVC

今天要不来聊聊SpringMVC吧?

  1. 我先简单说下我对SpringMVC的理解哈
  2. SpringMVC我觉得它是对Servlet的封装,屏蔽掉Servlet很多的细节卫
  3. 接下来我举几个例子
  4. 可能我们刚学Servlet的时候,要获取参数需要不断的getParameter
  5. 现在只要在SpringMVC方法定义对应的J avaBean,只要属性名与参数名一致,SpringMVC就可以帮我们实现「将参数封装到JavaBean」上了
  6. 又比如,以前使用Servlet 「上传文件」,需要处理各种细节,写一大堆处理的逻辑(还得导入对应的jar)
  7. 现在一个在SpringMVC的方法上定义出MultipartFile接口,又可以屏蔽掉上传文件的细节了。
  8. 例子还有很多,我就不赘述了。

既然你说SpringMVC是对Servlet的封装,你了解SpringMVC请求处理的流程吗?

  1. 总体流程大概是这样的
    • 首先有个统一处理请求的入口
    • 随后根据请求路径找到对应的映射器
    • 找到处理请求的适配器
    • 4):拦截器前置处理
    • 5):真实处理请求(也就是调用真正的代码)
    • 6):视图解析器处理
    • 7):拦截器后置处理

嗯,了解,可以再稍微深入点吗?

  1. 统一的处理入口,对应SpringMVC下的源码是在DispatcherServlet下实现的
  2. 该对象在初始化就会把映射器、适配器、视图解析器、异常处理器、文件处理器等等给初始化掉
  3. 至于会初始化哪些具体实例,看下DispatcherServlet.properties就知道了,都配置在那了
  4. 所有的请求其实都会被doService方法处理,里边最主要就是调用doDispatch方法
  5. 通过doDispatch方法我们就可以看到整个SpringMVC处理的流程
  6. 查找映射器的时候实际就是找到「最佳匹配」的路径,具体方法实现我记得好像是在lookupHandlerMethod方法上
  7. 从源码可以看到「查找映射器」实际返回的是HandlerExecutionChain,里边有映射器Handler+拦截器List
  8. 前面提到的拦截器前置处理和后置处理就是用的HandlerExecutionChain中的拦截器List
  9. 获取得到HandlerExecutionChain后,就会去获取适配器,一般我们获取得到的就是RequestMappingHandlerAdapter
  10. 在代码里边可以看到的是,经常用到的@ResponseBody和@Requestbody的解析器
  11. 就会在初始化的时候加到参数解析器List中
  12. 得到适配器之后,就会执行拦截器前置处理
  13. 拦截器前置处理执行完后,就会调用适配器对象实例的hanlde方法执行真正的代码逻辑处理
  14. 核心的处理逻辑在invokeAndHandle方法中,会获取得到请求的参数并调用,处理返回值
  15. 参数的封装以及处理会被适配器的参数解析器进行处理,具体的处理逻辑取决于HttpMessageConverter的实例对象

嗯,了解了。要不你再压缩下关键的信息

  1. DispatcherServlet (入口)

  2. DispatcherServlet.properties(会初始化的对象)

  3. HandlerMapping (映射器,

  4. HandlerExecutionChain(映射器最终实例+拦截器List)

  5. HttpRequestHandlerAdapter(适配器

  6. HttpMessageConverter(数据转换

l

16、【对线面试官】SpringBean生命周期

今天要不来聊聊Spring对Bean的生命周期管理?

  1. 嗯,没问题的。
  2. 很早之前我就看过源码,但Spring源码的实现类都太长了
  3. 我也记不得很清楚某些实现类的名字
  4. 要不我大概来说下流程?
  5. 首先要知道的是:普通Java对象和Spring.所管理的Bean实例化的过程是有些区别的
    在普通Java环境下创建对象简要的步骤可以分为以下几步:
    • java源码被编译为被编译为class文件
    • 等到类需要被初始化时(比如说new、反射等)
    • class文件被虚拟机通过类加载器加载到JVM
    • 初始化对象供我们使用
  6. 简单来说,可以理解为它是用Class对象作为「模板」进而创建出具体的实例
  7. 而Spring所管理的Bean不同的是,除了Class.对象之外,还会使用BeanDefinition的实例来描述对象的信息
  8. 比如说,我们可以在Spring.所管理的Bean有一系列的描述:@Scope、@Lazy、@DependsOn等等
  9. 可以理解为:Class只描述了类的信息,而BeanDefinition:描述了对象的信息

你就是想告诉我,Spring有BeanDefinition来存储着我们日常给Spring Bean定义的元数据(@Scope、@Lazy、@DependsOn等等),对吧?

  1. Spring在启动的时候需要「扫描」在XML/注解/JavaConfig中需要被Spring管理的Bean信息
  2. 随后,会将这些信息封装成BeanDefinition,最后会把这些信息放到一个beanDefinitionMap中
  3. 我记得这个Map的key应该是beanName,value则是BeanDefinition.对象
  4. 到这里其实就是把定义的元数据加载起来,目前真实对象还没实例化
  5. 接着会遍历这个beanDefinitionMap,执行BeanFactoryPostProcessor这个Bean工厂后置处理器的逻辑
  6. 比如说,我们平时定义的占位符信息,就是通过BeanFactoryPostProcessor的子类PropertyPlaceholderConfigurer进行注入进去
  7. 当然了,这里我们也可以自定义BeanFactoryPostProcessor来对我们定义好的Bean元数据进行获取或者修改。只是一般我们不会这样干,实际上也很有少的使用场景。
  8. BeanFactoryPostProcessor/后置处理器执行完了以后,就到了实例化对象啦
  9. 在Spring.里边是通过反射来实现的,一般情况下会通过反射选择合适的构造器来把对象实例化
  10. 但这里把对象实例化,只是把对象给创建出来,而对象具体的属性是还没注入的。
  11. 比如我的对象是UserService,而UserService对象依赖着SendService对象,这时候的SendService还是null的
  12. 所以,下一步就是把对象的相关属性给注入
  13. 相关属性注入完之后,往下接着就是初始化的工作了
  14. 首先判断该Bean是否实现了Aware相关的接口,如果存在则填充相关的资源
    比如我这边在项目用到的:我希望通过代码程序的方式去获取指定的Spring Bean
  15. 我们这边会抽取成一个工具类,去实现ApplicationContextAware接口,来获取ApplicationContexti对象进而获取Spring Bean
  16. Aware相关的接口处理完之后,就会到BeanPostProcessor后置处理器啦
  17. BeanPostProcessor后置处理器有两个方法,一个是before,一个是after。
    (那肯定是before先执行、after)后执行)
  18. 这个BeanPostProcessor)后置处理器是AOP实现的关键
    关键子类AnnotationAwareAspectJAutoProxyCreator
  19. 所以,执行完Aware相关的接口就会执行,BeanPostProcessor相关子类的before方法。
  20. BeanPostProcessor相关子类的before方法执行完,则执行init相关的方法,比如说@PostConstruct、实现了InitializingBean接口、定义的init-method方法
  21. 当时我还去官网去看他们的被调用「执行顺序」分别是:@PostConstruct、实现了InitializingBean:接口以及init-nethod方法
  22. 这些都是Spring:给我们的「扩展」,像@PostConstruct我就经常用到
  23. 比如说:对象实例化后,我要做些初始化的相关工作或者就启个线程去Kafka拉取数据
  24. 等到init方法执行完之后,就会执行BeanPostProcessor的after方法
  25. 基本重要的流程已经走完了,我们就可以获取到对象去使用了
  26. 销毁的时候就看有没有配置相关的destroy方法,执行就完事了

你看过Spring:是怎么解决循环依赖的吗?如果现在有个A对象,它的属性是B对象,而B对象的属性也是A对象说白了就是A依赖B,而B又依赖A,Spring是怎么做的?

  1. 从上面我们可以知道,对象属性的注入在对象实例化之后的嘛。
  2. 它的大致过程是这样的:首先A对象实例化,然后对属性进行注入,发现依赖B对象
    B对象此时还没创建出来,所以转头去实例化B对象
  3. B对象实例化之后,发现需要依赖A对象,那A对象已经实例化了嘛,所以B对
    象最终能完成创建
  4. B对象返回到A对象的属性注入的方法上,A对象最终完成创建。这就是大致的过程。

哦?听起来你还会原理哦?

  1. 至于原理,其实就是用到了三级的缓存
  2. 所谓的三级缓存其实就是三个Map.…首先明确一定,我对这里的三级缓存定义是这样的:
    singletonObjects(一级,日常实际获取Bean的地方);
  3. earlySingletonObjects(二级,已实例化,但还没进行属性注入,由三级缓存放进来);
  4. singletonFactories(三级,Value:是一个对象工厂);
  5. 再回到刚才讲述的过程中,A对象实例化之后,属性注入之前,其实会把A对象放入三级缓存中
  6. key是BeanName,Value:是ObjectFactory
  7. 等到A对象属性注入时,发现依赖B,又去实例化B时
  8. B属性注入需要去获取A对象,这里就是从三级缓存里拿出ObjectFactory,从ObjectFactory得到对应的Bean(就是对象A)
  9. 把三级缓存的A记录给干掉,然后放到二级缓存中
  10. 显然,二级缓存存储的key是BeanName,value就是Bean(这里的Bean还没做完属性注入相关的工作)
  11. 等到完全初始化之后,就会把二级缓存给remove掉,塞到一级缓存中
  12. 我们自己去getBean的时候,实际上拿到的是一级缓存的
  13. 大致的过程就是这样

那我想问一下,为什么是三级缓存?

  1. 首先从第三级缓存说起(就是key是BeanName,Value为ObjectFactory)
  2. 我们的对象是单例的,有可能A对象依赖的B对象是有AOP的(B对象需要代理)
  3. 假设没有第三级缓存,只有第二级缓存(Value存对象,而不是工厂对象)
  4. 那如果有AOP的情况下,岂不是在存入第二级缓存之前都需要先去做AOP代理?这不合适嘛
  5. 这里肯定是需要考虑代理的情况的,比如A对象是一个被AOP增量的对象,B依赖A时,得到的A肯定是代理对象的
  6. 所以,三级缓存的Value是ObjectFactory,可以从里边拿到代理对象
  7. 而二级缓存存在的必要就是为了性能,从三级缓存的工厂里创建出对象,再扔到二级缓存(这样就不用每次都要从工厂里拿)

总结

  1. 首先是Spring Bean的生命周期过程,Sprng使用BeanDefinition:来装载着我们给Bean定义的元数据

  2. 实例化Bean的时候会遍历BeanDefinitionMap

  3. Springl的Bean实例化和属性赋值是分开两步来做的

  4. 在Spring Beanl的生命周期,Spring预留了很多的hook给我们去扩展

    1):Bean实例化之前有BeanFactoryPostProcessor
    2):Bean实例化之后,初始化时,有相关的Aware接口供我们去拿到Context相关信息
    3):环绕着初始化阶段,有BeanPostProcessor(AOP的关键)
    4):在初始化阶段,有各种的init方法供我们去自定义

  5. 而循环依赖的解决主要通过三级的缓存

  6. 在实例化后,会把自己扔到三级缓存(此时的key是BeanName,Value是ObjectFactory)

  7. 在注入属性时,发现需要依赖B,也会走B的实例化过程,B属性注入依赖A,从三级缓存找到A

  8. 删掉三级缓存,放到二级缓存

l

15、【对线面试官】Spring基础

要不你来讲讲Spring的IOC和AOP你是怎么理解的呗?

  1. 我个人理解下:SpringIOC解决的是对象管理和对象依赖的问题。
  2. 本来是我们自己手动new出来的对象,现在则把对象交给Spring的IOC容器管理
  3. IOC容器可以理解为一个对象工厂,我们都把该对象交给工厂,工厂管理这些对象的创建以及依赖关系
  4. 等我们需要用对象的时候,从工厂里边获取就好了

哦,你说的就是「控制反转」和「注入依赖」吧?

  1. 我认为「控制反转」指的就是:把原有自己掌控的事交给别人去处理
  2. 它更多的是一种思想或者可以理解为设计模式
  3. 比如:本来由我们自己new出来的对象,现在交由IOC容器,把对象的控制权交给它方了.
  4. 而「依赖注入」在我的理解下,它其实是「控制反转」的实现方式
  5. 对象无需自行创建或者管理它的依赖关系,依赖关系将被「自动注入」到需要它们的对象当中去

嗯,那我想问问,用SpringIOC有什么好处吗?

或者换个问法:本来我可以new出来的对象,为什么我要交由Spring IOC容器管理呢?

  1. 主要的好处在于「将对象集中统一管理」并且「降低耦合度」
  2. 如果面试官理解了「工厂模式」,那就知道为什么我们不直接new对象
  3. 要说理由的话,可以举很多例子,比如说:
  4. 我用SpringIOC可以方便单元测试、对象创建复杂、对象依赖复杂、单例等等的,什么都可以交给Spring IOC
  5. 理论上自己new出来的都可以解决上面的问题,Spring在各种场景组合下有可能不是最优解
  6. 但new出来的你要自己管理,可能你得自己写工厂,得实现一大套的东西才能满足需求
  7. 写着写着有可能还是Spring的那一套
  8. 但现在Spring现在已经帮你实现了啊!
  9. 如果项目里的对象都是就new下就完事了,没有多个实现类,那没事,不用Spring也没啥问题
  10. 并且Spring核心不仅仅IOC啊,除了把对象创建出来,还有一整套的Bean生命周期管理
  11. 比如说你要实现对象增强,AOP不就有了吗?不然你还得自己创建代理

那你继续来聊下Spring AOP呗?

  1. Spring AOP解决的是非业务代码抽取的问题
  2. AOP底层的技术是动态代理,在Spring 内实现依赖的是BeanPostProcessor
  3. 比如我们需要在方法上注入些「重复性」的非业务代码,就可以利用Spring AOP
  4. 所谓的「面向切面编程」在我理解下其实就是在方法前后增加非业务代码

那你在工作中实际用到过AOP去优化你的代码吗?

  1. ·有的。当时我用AOP来对我们公司现有的监控客户端进行封装
  2. 一个系统离不开监控,监控基本的指标有QPS、RT、ERROR等等
  3. 对外暴露的监控客户端只能在代码里写对应的上报信息(灵活,但会与业务代码掺杂在一起)
  4. 于是我利用注解+AOP的方式封装了一把,只要方法/类上带有我自定义的注解
  5. 方法被调用时,就会上报AQS、RT等信息实现了非业务代码与业务代码分离的效果

了解,你们项目一般是怎么把对象交给IOC容器管理的?

换个问法:一般是怎么定义Bean的?

  1. Spring提供了4种方式,分别是:
    1):注解2):XML3):JavaConfig 4):基于Groovy 的DSL配置
  2. 一般项目我们用注解或XML比较多,少部分用JavaConfig
  3. 日常写业务代码一般用注解来定义各种对象,责任链这种一般配置在XML「注解」解决不了的就用JavaConfig
  4. 总体而言,还是得看项目的代码风格吧
  5. 反正就是定义元数据,能给到Spring解析就好了

要不来聊聊你使用Spring的感受?

  1. 当我还是初学Spring的时候,我觉得Spring很麻烦,需要有一大堆的配置信息才能跑起来
  2. 光是搭建环境就需要耗费我好长的时间
  3. 毕竟版本冲突,依赖冲突什么的就可能个下午就过去了
  4. 但毕竟一个系统环境只搭一次嘛,所以还好(后来用上了SpringBoot这又更方便了)
  5. 回来,IOC和AOP在工作用的时候还是很爽的
  6. 毕竟搞个注解什么的,配置下就可以把对象交给Spring管理了
  7. 配合Spring的生态,@Transactional注解什么的,都好用得飞起
  8. 不过,Spring给我们封装得太好了
  9. 经常就会有奇奇怪怪的”bug”出现,也踩过很多的坑了
  10. Bean经常没办法创建成功,导致项目启动失败..
  11. 对象的循环依赖问题.
  12. 同一个接口,多个实现,识别不出我要创建哪个对象.
  13. 为什么catch了异常,Spring事务为什么还会自动回滚..
  14. 等等等

循环以来

  1. 到这里,Spring整个解决循环依赖问题的实现思路已经比较清楚了。对于整体过程,读者朋友只要理解两点:

    • Spring是通过递归的方式获取目标bean及其所依赖的bean的;
    • Spring实例化一个bean的时候,是分两步进行的,首先实例化目标bean,然后为其注入属性。

    结合这两点,也就是说,Spring在实例化一个bean的时候,是首先递归的实例化其所依赖的所有bean,直到某个bean没有依赖其他bean,此时就会将该实例返回,然后反递归的将获取到的bean设置为各个上层bean的属性的。

l

17、【对线面试官】Redis基础

你先来讲讲为什么要用Redis吧?

  1. 我个人是这样理解的:无论Redis也好、MySQL也好、HDFS也好、HBase也好
  2. 他们都是存储数据的地方
  3. 因为它们的设计理念的不同,我们会根据不同的应用场景使用不同的存储
  4. 像Redis一般我们会把它用作于缓存
  5. 当然啦,日常有的应用场景比较简单,用个HashMap也能解决很多的问题了,没必要上Redis
  6. 这就好比,有的单机限流可能应对某些场景就够用了,也没必要说一定要上分布式限流把系统搞得复杂

那你在项目里有用到Redis吗?怎么用的?

  1. Redis肯定是用到的,我负责的项目几乎都会有Redisl的踪影
  2. 我举几个我这边项目用的案例呗?
  3. 我这边负责消息管理平台,简单来说就是发消息的
  4. 那发完消息肯定我们是得知道消息有没有下发成功的,是吧?
  5. 于是我们系统有一套完整的链路追踪体系
  6. 其中实时的数据我们就用Redis来进行存储,有实时肯定就会有离线的嘛(离线的数据我们是存储到Hive的)
  7. 对消息进行实时链路追踪,我这边就用了Redis好几种的数据结构,分别有Set、List和Hash
  8. 我再稍微铺垫下链路追踪的背景吧~
  9. 要在消息管理平台发消息,首先得在后台新建一个「模板」,有模板自然会有一个模板ID
  10. 对模板D进行扩展,比如说加上日期和固定的业务参数,形成的ID可以唯一标识某个模板的下发链路
  11. 在系统上,我这边叫它为UMPID
  12. 在发送入口处会对所有需要下发的消息打上UMPID,然后在关键链路上打上对应的点位
  13. 接下来的工作就是清洗出统一的模型,然后根据不同维度进行处理啦。比如说:
  14. 我要看某一天下发的所有模板有哪些,那只要我把清洗出来后数据的,将对应UMPID扔到了Set就好了
  15. 我要看某一个模板的消息下发的整体链路情况,那我以UMPID为Key,Value是Hash结构,Key是state,Value则是人数
  16. 这里的state我们在下发的过程中打的关键点位,比如接收到消息打个51,消息被去重了打个61,消息成功下发了打个81…
  17. 以UMPID为Key,Hash结构的Key(State)进行不断的累加,就可以实现某一个模板的消息下发的整体链路情况
  18. 我要看某个用户当天下发的消息有哪些,以及这些消息的整体链路是如何。
  19. 这边我用的是List结构,Key是userld,Value则是UMPID+state(关键点位)+processTime(处理时间)
  • 简单来说,就是通过Redis丰富的数据结构来实现对下发消息多个维度的统计
  • 不同的应用场景选择不同的数据结构,再等到透出做处理的时候,就变得十分简单了
  • 消息下发过程中去重或者一般正常的场景就直接Key-Value就能符合需求了
  • 像bitmap、hyperloglogs、sortset、steam等等这些数据结构在我所负责的项目用得是真不多
  • 要是我有机会去到贵公司,贵公司有相关的应用场景,我相信我也很快就能掌握
  • 这些数据结构底层都由对应的object来支撑着,objecti记录对应的「编码」
  • 其实就是会根据key-value存储的数量或者长度来使用选择不同的底层数据结构实现
  • 比如说:ziplist压缩列表这个底层数据结构有可能上层的实现是list、hash和sortset
  • Hash结构的底层数据结构可能是hash和ziplist
  • 在节省内存和性能的考量之中切换,Redis还是有点屌的啊。

就你上面那个实时链路场景,可以用其他的存储替代吗?

  1. 嗯,理论上是可以的(或许可以尝试用HBase),但总体来说没这么好吧
  2. 因为Redis拥有丰富的数据结构,在透出的时候,处理会非常的方便。
  3. 如果不用Redis的话,还得做很多解析的工作
  4. 并且,我那场景的并发还是相当大的(就一条消息发送,可能就产生10条记录)
  5. 监控峰值命令处理数会去到20K+QPS,当然了,这场景我肯定用了Pipeline的(不然处理会慢很多)
  6. 综合上面并发量和实时性以及数据结构,用Redis:是一个比较好的选择。

你觉得为什么Redis可以这么快?

  1. 首先,它是纯内存操作,内存本身就很快
  2. 其次,它是单线程的,Redis服务器核心是基于非阻塞的O多路复用机制,单线程避免了多线程的频繁上下文切换问题
  3. 至于这个单线程,其实官网也有过说明(:表示使用Redis往往的瓶颈在于内与和网络,而不在于CPU
l

18、【对线面试官】Redis持久化

嗯,开始吧,今天要不来聊聊Redisl的持久化机制吧?

  1. 在上一次面试已经说过了Redis:是基于内存的

  2. 假设我们不做任何操作,只要Redis服务器重启(或者中途故障挂掉了),那内存的数据就会没掉

  3. 所以Redis:提供了持久化机制给我们用,分别是RDB和AOF

    1)RDB指的就是:根据我们自己配置的时间或者手动去执行BGSAVE或SAVE命令,Redisi就会去生成RDB文件

    2)这个RDB文件实际上就是一个经过压缩的二进制文件,Redis可以通过这个文件在启动的时候来还原我们的数据

    1)而AOF则是把Redis服务器接收到的所有写命令都记录到日志中

    2)Redis重跑一遍这个记录下的日志文件,就相当于还原了数据

那我就想问了,你上次不是说Redis是单线程吗?那比如你说的RDB,它会执行SAVE或BESAVE命令,生成文件。那不是非常耗时的吗,那如果只有一个线程处理,那其他的请求不就得等了?

  1. 嗯,没错,Redis是单线程的。
  2. 以RDB持久化的过程为例,假设我们在配置上是定时去执行RDB存储
  3. Redis有自己的一套事件处理机制,主要处理文件事件(命令请求和应答等等)和时间事件(RDB定时持久化、清理过期的Key等的)
  4. 所以,定时的RDB实际上就是一个时间事件
  5. 线程不停地轮询就绪的事件,发现RDB的事件可执行时,则调用BGSAVE命令
  6. 而BGSAVE命令实际上会fork出一个子进程来进行完成持久化(生成RDB文件)
  7. 在fork的过程中,父进程(主线程)肯定是阻塞的。
  8. 但fork完之后,是fork出来的子进程去完成持久化。处理请求的进程该干嘛的就干嘛
  9. 所以说啊,Redis:是单线程,理解是没错的,但没说人家不能fork进程来处理事情。
  10. 还有就是,其实Redis在较新的版本中,有些地方都使用了多线程来进行处理
  11. 比如说,一些删除的操作(UNLINK、FLUSHALL ASYNC等等)还有Redis6.x之后对网络数据的解析都用了多线程处理了。
  12. 只不过,核心的处理命令请求和响应还是单线程。

那AOF呢?AOF不是也要写文件吗?难道也是fork了个子进程去做的?

  1. emm,不是的。AOF是在命令执行完之后,把命令写在buffer缓冲区的(直接追加写)
  2. 那想要持久化,肯定得存盘嘛。Redis:提供了几种策略供我们选择什么时候把缓冲区的数据写到磁盘
  3. 我记得好像有:每秒一次/每条命令都执行从不存盘;一般我们会选每秒一次
  4. Redis会启一个线程去刷盘,也不是用主线程去干的

那如果把执行过的命令都存起来;等启动的时候是可以再把这些写命令再执行一遍,达到恢复数据的效果;这样会有什么样的问题吗?

  1. 嗯,问题就是,如果这些写入磁盘的「命令集合」不做任何处理,那该「命令集合」就会一直膨胀
  2. 其实就是该文件会变得非常大
  3. Redis当然也考虑了这一点,它会fork个子进程会对「原始」命令集合进行重写
  4. 说白了就是会压缩,压缩完了之后只要替换原始文件就好了

那我又想问了,既然它是fork一个进程来对AOF进行重写的;前面你也提到了再fork时,主进程是阻塞的,但fork后,主进程会继续接收命令;你是说重写完(压缩)会进行文件覆盖;那这样不会丢数据吗?毕竟主进程在fork之后是一直会接收命令的

  1. 其实做法很简单啊,在fork子进程之后,把新接收到命令再写到另一个缓冲区不就好了吗

那AOF和RDB用哪一个呢?

  1. 主要是看业务场景吧,我们这边是基于Redis使用了一套开源的key-value存储
  2. 使用Redis前,首先要去新增实例,在新增时会让你选择对应的使用场景
  3. 就是会让你通过不同的应用场景进行配置选择
  4. 比如说,业务上是允许重启时部分数据丢失的,那RDB就够用了
  5. RDB在启动的时候恢复数据会比AOF快很多
  6. 在Redis4.0以后也支持了AOF和RDB混合
  7. 至于AOF的话,官网是不建议仅仅只使用AOF的,如果对数据丢失容忍度是有要求的,建议是开启AOF+RDB一起用
  8. 总的来说,不同的场景使用不同的持久化策略吧
  9. 我们公司也是不建议把Redis当做存储去使用的(毕竟没有事务保证,也还是可能导致数据丢失)

顺便我想问下,假如Redisl的内存满了,但业务还在写数据,会怎么样?

  1. 嗯,这个问题我也遇到过
  2. 一般来说,我们会淘汰那些「不活跃」的数据,然后把新的数据写进去
  3. 更多情况下,还是做好对应的监控和容量的考量吧。等容量达到阈值的时候,及时发现和扩容

那要不来讲讲扩容和Redisl的架构吧?

下次吧

那要不来讲讲扩容和Redisl的架构吧?

  1. Redis的官网啊,看了这么多技术官网,我觉得Redis的官网弄得是真不错
  2. 《Redis设计与实现》这本书也挺不错的
l

19、【对线面试官】kafka基础

今天要不来聊聊消息队列吧?我看你项目不少地方都写到Kafka了.你简单说明下你使用Kafka的场景吧

  1. 使用消息队列的目的总的来说可以有三种情况:解耦、异步和削峰

  2. 比如举我项目的例子吧,我现在维护一个消息管理平台系统,对外提供发送接口给各个业务方调用

  3. 他们调用接口之后,实际上『不是同步』下发了消息。

  4. 在接口处理层只是把该条消息放到了消息队列上,随后就直接返回结果给接口调用者了。

  5. 这样的好处就是:

    1)接口的吞吐量会大幅度提高(因为未做真正实际调用,接口RT会非常低)【异步】

    2)即便有大批量的消息调用接口都不会让系统受到影响(流量由消息队列承载)【削峰】

有点抽象,再举个实际案例?

  1. 又比如说,我这边还有个项目是广告订单归因工程,主要做的事情就是得到订单数据,给各个业务广告计算对应的佣金。

  2. 订单的数据是从消息队列里取出的

  3. 这样设计的好处就是:

    1)交易团队的同学只要把订单消息写到消息队列,该订单数据的Topic由各个业务方自行消费使用【解耦】【异步】

    2)即便下单QPS猛增,对下游业务无太大的感知(因为下游业务只消费消息队列的数据,不会直接影响到机器性能)【削峰】

那我想问下,你觉得为什么消息队列能削峰?或者换个问法,为什么Kafka能承载这么大的QPS?

  1. 消息队列「最核心的功能就是把生产的数据存储起来,然后给各个业务把数据再读取出来。

  2. 跟我们处理请求时不一样,我们在业务处理时可能会调别人的接口,可能会需要去查数据库…等等等一系列的操作才行

  3. 这些业务操作都是非常耗时的,像Kafka在「存储」和「读取」这个过程中又做了很多的优化

  4. 举几个例子,比如说:

    1)我们往一个Topic发送消息或者读取消息时,实际内部是多个Partition在处理【并行】

    2)在存储消息时,Kafka内部是顺序写磁盘的,并且利用了操作系统的缓冲区来提高性能【append+cache】

    3)在读写数据中也减少CPU拷贝的次数【零拷贝】

嗯,你既然提到减少CPU拷贝的次数,可以给我说下这项技术吗?

  1. 嗯,可以的,其实就是零拷贝技术。

  2. 比如我们正常调用read函数时,会发生以下的步骤(以读磁盘的数据为例):

    1)DMA把磁盘数据拷贝到读内核缓存区

    2)CPU把读内核缓冲区的数据拷贝到用户空间

  3. 正常调用write函数时,会发生以下的步骤(数据写到网卡为例):

    1)CPU把用户空间的数据拷贝到Socket内核缓存区

    2)DMA把Socket内核缓冲区的数据拷贝到网卡

  4. 可以发现完成「一次读写」需要2次DMA拷贝,2次CPU拷贝。

  5. 而DMA拷贝是省不了的,所谓的零拷贝技术就是把CPU的拷贝给省掉

  6. 并且为了避免用户进程直接操作内核,保证内核安全,应用程序在调用系统函数时,会发生上下文切换(上述的过程一共会发生4次)

  7. 目前零拷贝技术主要有:mmap和sendfile

  8. 比如说:mmap是将读缓冲区的地址和用户空间的地址进行映射,实现读内核缓冲区和应用缓冲区共享

  9. 从而减少了从读缓冲区到用户缓冲区的一次CPU拷贝

  10. 使用mmap的后一次读写就可以简化为:、

    一、DMA把硬盘数据拷贝到读内核缓冲

    二、CPU把读内核缓存区拷贝至Socket内核缓冲区。

    三、DMA把Socket内核缓冲区拷贝至网

  11. 由于读内核缓冲区与用户空间做了映射,所以会省了一次CPU拷贝

  12. 而sendfile+DMA Scatter/Gather!则是把读内核缓存区的文件描述符/长度信息发到Socket内核缓冲区,实现CPU零拷贝

  13. 使用sendfile+DMA Scatter/Gather一次读写就可以简化为:

    1)DMA把硬盘数据拷贝至读内核缓冲区

    2)CPU把读缓冲区的文件描述符和长度信息发到Socket缓冲区。

    3)DMA根据文件描述符和数据长度从读内核缓冲区把数据拷贝至网卡

  14. 回到kafka上吧

  15. 从Producer-》Broker,Kafka是把网卡的数据持久化硬盘,用的是mmap(从2次
    CPU拷贝减至1次)

  16. 从Broker-》Consumer,Kafka是从硬盘的数据发送至网卡,用的是sendFile(实
    现CPU零拷贝)

总结

Kafka能这么快的原因就是实现了并行、充分利用操作系统cache、顺序写和零拷贝

l

20、【对线面试官】使用kafka会考虑什么问题

你提到了你这边会从交易的消息报获取到订单的数据,然后做业务的处理;也提到了你用的是Kafka,我想问下,Kafka会丢数据吗?

  1. 嗯,使用Kafkal时,有可能会有以下场景会丢消息

  2. 比如说,我们用Producer发消息至Broke的时候,就有可能会丢消息

  3. 如果你不想丢消息,那在发送消息的时候,需要选择带有callBack的api进行发送

  4. 其实就意味着,如果你发送成功了,会回调告诉你已经发送成功了。如果失败了,那收到回调之后自己在业务上做重试就好了。

  5. 等到把消息发送到Brokerl以后,也有可能丢消息

  6. 一般我们的线上环境都是集群环境下嘛,但可能你发送的消息后broker就挂了,这时挂掉的broker还没来得及把数据同步给别的broker,数据就自然就丢了

  7. 发送到Broker之后,也不能保证数据就一定不丢了,毕竟Broker会把数据存储到磁盘之前,走的是操作系统缓存

  8. 也就是异步刷盘这个过程还有可能导致数据会丢

  9. 嗯,到这里其实我已经说了三个场景了,分别是:producer-》broker,broker-》broker之间同步,以及broker-》磁盘

  10. 要解决上面所讲的问题也比较简单,这块也没什么好说的…

  11. 不想丢数据,那就使用带有callback的api设置acks、retries、factor等等些参数来保证Producer发送的消息不会丢就好啦。

一般来说,还是client消费broker丢消息的场景比较多;那你们在消费数据的时候是怎么保证数据的可靠性的呢?

  1. 首先,要想client端消费数据不能丢,肯定是不能使用autoCommit的,所以必须是手动提交的。

  2. 我们这边是这样实现的:

    一、从Kafka拉取消息、(一次批量拉取500条,这里主要看配置)
    二、为每条拉取的消息分配一个msgld(递增)
    三、将msgld存入内存队列(sortSet)中
    四、使用Map存储msgld.与msg(有offset相关的信息)的映射关系

    五、当业务处理完消息后,ack时,获取当前处理的消息nsgld,然后从sortSet删除该msgld(此时代表已经处理过了)
    六、接着与sortSet队列的首部第一个ld比较(其实就是最小的msgld),如果当前msgld<=sort Set第一个ID,则提交当前offset

    七、系统即便挂了,在下次重启时就会从sortSet队首的消息开始拉取,实现至少处理一次语义
    八、会有少量的消息重复,但只要下游做好幂等就OK了

嗯,你也提到了幂等,你们是怎么实现幂等性的呢?

  1. 嗯,还是以处理订单消息为例好了。
  2. 幂等Key我们由订单编号+订单状态所组成(一笔订单的状态只会处理一次)
  3. 在处理之前,我们首先会去查Redis:是否存在该Key,如果存在,则说明我们已经处理过了,直接丢掉
  4. 如果Redis没处理过,则继续往下处理,最终的逻辑是将处理过的数据插入到业务DB上,再到最后把幂等Key插入到Redis上
  5. 显然,单纯通过Redis是无法保证幂等的
  6. 所以,Redis其实只是一个「前置」处理,最终的幂等性是依赖数据库的唯一Key来保证的(唯一Key实际上也是订单编号+状态)
  7. 而插入DB是依赖事务的,所以是没问题的
  8. 总的来说,就是通过Redis做前置处理,DB唯一索引做最终保证来实现幂等性的

你们那边遇到过顺序消费的问题吗?

  1. 嗯,也是有的,我举个例子

  2. 订单的状态比如有支付、确认收货、完成等等,而订单下还有计费、退款的消息报

  3. 理论上来说,支付的消息报肯定要比退款消息报先到嘛,但程序处理的过程中可不一定的嘛

  4. 所以在这边也是有消费顺序的问题(先处理了支付,才能退款啊)

  5. 但在广告场景下不是「强顺序」的,只要保证最终一致性就好了。

  6. 所以我们这边处理「乱序」消息的实现是这样的:

    1)宽表:将每一个订单状态,单独分出一个或多个独立的字段。消息来时只更新对应的字段就好,消息只会存在短暂的状态不一致问题,但是状态最终是一致的

    2)消息补偿机制:另一个进行消费相同topicl的数据,消息落盘,延迟处理。将消息与DB进行对比,如果发现数据不一致,再重新发送消息至主进程处理

    3)还有部分场景,可能我们只需要把相同userld/orderld.发送到相同的partition(因为一个partition由一个Consumer消费),又能解决大部分消费顺序的问题了呢。

l

21、【对线面试官】Mysql索引

我看你简历上写了MySQL,对MySQL InnoDB引擎的索引了解吗?

  1. 嗯啊,使用索引可以加快查询速度,其实上就是将无序的数据变成有序(有序就能加快检索速度)
  2. 在InnoDB引擎中,索引的底层数据结构是**B+树**

那为什么不使用红黑树或者B树呢?

  1. MySQL的数据是存储在硬盘的,在查询时一般是不能「一次性」把全部数据加载到内存中

  2. 红黑树是「二叉查找树」的变种,一个Node节点只能存储一个Key和一个Value

  3. B和B+树跟红黑树不一样,它们算是「多路搜索树」,相较于「二叉搜索树」而言,一个Node节点可以存储的信息会更多,「多路搜索树」的高度会比「二叉搜索树」更低。

  4. 了解了区别之后,其实就很容易发现,在数据不能一次加载至内存的场景下,数据需要被检索出来

  5. 选择B或B+树的理由就很充分了(一个Node节点存储信息更多(相较于二叉搜索树),树的高度更低,树的高度影响检索的速度)

  6. B+树相对于B树而言,它又有两种特性。

    1)B+树非叶子节点不存储数据,在相同的数据量下,B+树更加矮壮。(这个应该不用多解释了,数据都存储在叶子节点上,非叶子节点的存储能存储更多的索引,所以整棵树就更加矮壮)

    2)B+树叶子节点之间组成一个链表,方便于遍历查询(遍历操作在MySQL中比较常见)

  7. 我稍微解释一下吧,你可以脑补下画面

  8. 我们在MySQL InnoDB引擎下,每创建一个索引,相当于生成了一颗B+树。

  9. 如果该索引是「聚集(聚簇)索引」,那当前B+树的叶子节点存储着「主键和当前行的数据」

  10. 如果该索引是「非聚簇索引小」,那当前B+树的叶子节点存储着「主键和当前索引列值」

  11. 比如写了一句sql:select*from user where id>=10,那只要定位到id为10的记录,然后在叶子节点之间通过遍历链表(叶子节点组成的链表),即可找到往后的记录了。

  12. 由于B树是会在非叶子节点也存储数据,要遍历的时候可能就得跨层检索,相对麻烦些。

  13. 基于树的层级以及业务使用场景的特性,所以MySQL选择了B+树作为索引的底层数据结构。

  14. 对于哈希结构,其实InnoDB引擎是「自适应」哈希索引的(hash索引的创建由lnnoDB存储引擎自动优化创建,我们是干预不了)

你知道什么是回表吗?

  1. 所谓的回表其实就是,当我们使用非聚簇索引查询数据时,检索出来的数据可能包含其他列
  2. 但走的索引树叶子节点只能查到当前列值以及主键ID,所以需要根据主键ID再去查一遍数据,得到SQL所需的列
  3. 举个例子,我这边建了给订单号ID建了个索引,但我的SQL是:select orderld,orderName from orderdetail where orderld = 123
  4. SQL走订单ID索引,但在订单ID的索引树的叶子节点只有orderld和ld,而我们还想检索出orderName,所以MySQL会拿到ID再去查出orderName给我们返回,这种操作就叫回表

如何避免回表

  1. 想要避免回表,可以使用覆盖索引
  2. 所谓的覆盖索引,实际上就是你想要查出的列刚好在叶子节点上都存在,比如我建了orderld和orderName.联合索引l,刚好我需要查询也是orderld和orderName,这些数据都存在索引树的叶子节点上,就不需要回表操作了。

既然你也提到了联合索引,我想问下你了解最左匹配原则吗

  1. 嗯,要说明这个概念,还是举例子比较容易
  2. 如有索引(a,b,c,d),查询条件a=1and b=2 and c>3 and d=4,则会在每个节点依次命中a、b、c,无法命中d
  3. 先匹配最左边的,索引只能用于查找key是否存在(相等),遇到范围查询(>、<、between、like左匹配)等就不能进一步匹配了,后续退化为线性查找
  4. 这就是最左匹配原则

嗯嗯,我还想问下你们主键是怎么生成的?

主键就自增的

那假设我不用MySQL自增的主键,你觉得会有什么问题呢?

  1. 首先主键得保证它的唯一性和空间尽可能短吧,这两块是需要考虑的。
  2. 另外,由于索引的特性(有序),如果生成像uuid类似的主键,那插入的的性能是比自增的要差的
  3. 因为生成的uuid,在插入时有可能需要移动磁盘块(比如,块内的空间在当前时刻已经存储满了,但新生成的uuid需要插入已满的块内,就需要移动块的数据)
l

这次我想问下,你是怎么理解InnoDB引擎中的事务的?

  1. 在我的理解下,事务可以使「一组操作」要么全部成功,要么全部失败
  2. 事务其目的是为了「保证数据最终的一致性」。
  3. 举个例子,我给你发支付宝转了888块红包。那自然我的支付宝余额会扣减888块,你的支付宝余额会增加888块。
  4. 而事务就是保证我的余额扣减跟你的余额增添是同时成功或者同时失败的,这样这次转账就正常了

嗯,那你了解事务的几大特性吗?

嗯,就是ACID嘛,分别是原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)。

  1. 原子性指的是:当前事务的操作要么同时成功,要么同时失败。原子性由undo Iog日志来保证,因为undo log记载着数据修改前的信息。

    1)比如我们要insert一条数据了,那undo Iog会记录的一条对应的delete日志。我们要update一条记录时,那undo log会记录之前的「旧值」的update记录。

    2)如果执行事务过程中出现异常的情况,那执行「回滚」。InnoDB引擎就是利用undo log记录下的数据,来将数据「恢复」到事务开始之前

  2. 隔离性指的是:在事务「并发」执行时,他们内部的操作不能互相干扰。

    1)如果多个事务可以在同一时刻操作同一份数据,那么就会可能会产生脏读、重复读、幻读的问题。

    2)于是,事务与事务之间需要存在「一定」的隔离。在nnoDB引擎中,定义了四种隔离级别供我们使用:分别是:read uncommit(读未提交)、read commit(读已提交)、repeatable read(可重复复读)、serializable(串行)

    3)不同的隔离级别对事务之间的隔离性是不一样的(级别越高事务隔离性越好,但性能就越低),而隔离性是由MySQL的各种锁来实现的,只是它屏蔽了加锁的细节。

  3. 持久性指的就是:一旦提交了事务,它对数据库的改变就应该是永久性的。说白了就是,会将数据持久化在硬盘上。

    1)而持久性由redo log日志来保证,当我们要修改数据时,MySQL是先把这条记录所在的「页」找到,然后把该页加载到内存中,将对应记录进行修改。

    2)为了防止内存修改完了,MySQL就挂掉了(如果内存改完,直接挂掉,那这次的修改相当于就丢失了)。

    3)MySQL引入了redo log,内存写完了,然后会写一份redo log,这份redo log记载着这次在某个页上做了什么修改。

    4)即便MySQL在中途挂了,我们还可以根据redo log:来对数据进行恢复。

    5)redo log是顺序写的,写入速度很快。并且它记录的是物理修改(xxxx页做了xxx修改),文件的体积很小,恢复速度也很快。

  4. 「一致性」可以理解为我们使用事务的「目的」,而「隔离性」「原子性」「持久性」均是为了保障「一致性」的手段,保证一致性需要由应用程序代码来保证

    1)比如,如果事务在发生的过程中,出现了异常情况,此时你就得回滚事务,而不是强行提交事务来导致数据不一致。

刚才你也提到了隔离性嘛,然后你说在MySQL中有四种隔离级别,能分别来介绍下吗?

  1. 嗯,为了讲清楚隔离级别,我顺带来说下MySQL锁相关的知识吧。

    1)在InnoDB引擎下,按锁的粒度分类,可以简单分为行锁和表锁。

    2)行锁实际上是作用在索引之上的(索引上次已经说过了,这里就不赘述了)

    3)当我们的SQL命中了索引,那锁住的就是命中条件内的索引节点(这种就是行锁),如果没有命中索引,那我们锁的就是整个索引树(表锁)。

    4)简单来说就是:锁住的是整棵树还是某几个节点,完全取决于SQL条件是否有命中到对应的索引节点。

    5)而行锁又可以简单分为读锁(共享锁、S锁)和写锁(排它锁、X锁)。

    6)读锁是共享的,多个事务可以同时读取同一个资源,但不允许其他事务修改。写锁是排他的,写锁会阻塞其他的写锁和读锁。

  2. 我现在就再回到隔离级别上吧,就直接以例子来说明啦。

    1)首先来说下read uncommit(读未提交)。比如说:A向B转账,A执行了转账语句,但A还没有提交事务,B读取数据,发现自己账户钱变多了!B跟A说,我已经收到钱了。A回滚事务【rollback】,等B再查看账户的钱时,发现钱并没有多。

    ​ (1)简单的定义就是:事务B读取到了事务A还没提交的数据,这种用专业术语来说叫做「脏读」。

    ​ (2)对于锁的维度而言,其实就是在read uncommit隔离级别下,读不会加任何锁,而写会加排他锁。读什么锁都不加,这就让排他锁无法排它了。

    ​ (3)我们又知道,对于更新操作而言,lnnODB是肯定会加写锁的(数据库是不可能允许在同一时间,更新同一条记录的)。而读操作,如果不加任何锁,那就会造成上面的脏读。

    ​ (4)脏读在生产环境下肯定是无法接受的,那如果读加锁的话,那意味着:当更新数据的时,就没办法读取了,这会极大地降低数据库性能。

    • 在MySQL InnoDB引擎层面,又有新的解决方案(解决加锁后读写性能问题),叫做MVCC(Multi-Version Concurrency Control)多版本并发控制

    • 在MVCC下,就可以做到读写不阻塞,且避免了类似脏读这样的问题。那MVCC是怎么做的呢?

    • MVCC通过生成数据快照(Snapshot),并用这个快照来提供一定级别(语句级或事务级)的一致性读取

    (5)回到事务隔离级别下,针对于read commit(读已提交)隔离级别,它生成的就是语句级快照,而针对于repeatable read(可重复读),它生成的就是事务级的快照。

    2)前面提到过read uncommit隔离级别下会产生脏读,而read commit(读已提交)隔离级别解决了脏读

    ​ (1)思想其实很简单:在读取的时候生成一个”版本号”,等到其他事务commit了之后,才会读取最新已commit的”版本号”数据。

    ​ (2)比如说:事务A读取了记录(生成版本号),事务B修改了记录(此时加了写锁),事务A再读取的时候,是依据最新的版本号来读取的(当事务B执行commit了之后,会生成一个新的版本号),如果事务B还没有commit.那事务A读取的还是之前版本号的数据。

    ​ (3)通过「版本」的概念,这样就解决了脏读的问题,而通过「版本」又可以对应快照的数据。read commit(读已提交)解决了脏读。

    3)read commit(读已提交)解决了脏读,但也会有其他并发的问题。「不可重复读」:一个事务读取到另外一个事务已经提交的数据,也就是说一个事务可以看到其他事务所做的修改。

    ​ (1)不可重复读的例子:A查询数据库得到数据,B去修改数据库的数据,导致A多次查询数据库的结果都不一样【危害:A每次查询的结果都是受B的影响的】

    ​ (2)了解MVCC基础之后,就很容易想到repeatable read(可重复复读)隔离级别是怎么避免不可重复读的问题了(前面也提到了)。

    ​ (3)repeatable read(可重复复读)隔离级别是「事务级别」的快照!每次读取的都是「当前事务的版本」,即使当前数据被其他事务修改了(commit),也只会读取当前事务版本的数据。

    ​ (4)在InnoDB引擎下的的repeatable read(可重复复读)隔离级别下,在MVCC下,快照读,已经解决了幻读的问题(因为它是读历史版本的数据)

    ​ (5)而如果是当前读(比如select*from table for update),则需要配合间隙锁来解决幻读的问题。

    4)剩下的就是serializable(串行)隔离级别了,它的最高的隔离级别,相当于不允许事务的并发,事务与事务之间执行是串行的,它的效率最低,但同时也是最安全的。

我看你提到了MVCC了,不妨来说下他的原理?

  1. MVCC的主要是通过read view和undo log来实现的
  2. undo log前面也提到了,它会记录修改数据之前的信息,事务中的原子性就是通过undo log:来实现的。所以,有undo log可以帮我们找到「版本」的数据
  3. 而read view实际上就是在查询时,lnnoDB会生成一个read view,read view有几个重要的字段,看下去就懂了
  4. 分别是:trx ids(尚未提交commit的事务版本号集合),low limit id(下一次要生成的事务D值),low limit id(尚未提交版本号的事务D最小值)以及creator trx id(当前的事务版本号)
  5. 在每行数据有两列隐藏的字段,分别是DB_TRX_ID(记录着当前ID)以及DB_ROLL _PTR(指向上一个版本数据在undolog里的位置指针)
  6. 垫到这了,很容易就发现,MVCC其实就是靠「比对版本」来实现读写不阻塞,而版本的数据存在于undo log中。
  7. 而针对于不同的隔离级别(read commit和repeatable read),无非就是read commit隔离级别下,每次都获取一个新的read view,repeatable read隔离级别则每次事务只获取一个read view

总结

  • 事务为了保证数据的最终一致性

  • 事务有四大特性,分别是原子性、一致性、隔离性、持久性

    • 原子性由undo log保证
    • 持久性由redo log 保证
    • 隔离性由数据库隔离级别供我们选择,分别有read uncommit,read commit,repeatable read,serializable
    • 一致性是事务的目的,一致性由应用程序来保证
  • 事务并发会存在各种问题,分别有脏读、重复读、幻读问题。上面的不同隔离级别可以解决掉由于并发事务所造成的问题,而隔离级别实际上就是由MySQL锁来实现的

  • 频繁加锁会导致数据库性能低下,引入了MVCC多版本控制来实现读写不阻塞,提高数据库性能

  • MVCC原理即通过read view 以及undo log来实现

l

23、【对线面试官】Mysql调优

要不你来讲讲你们对MySQL是怎么调优的?

  • 哇,这命题很大阿…我认为,对于开发者而言,对MySQL的调优重点一般是在「开发规范」、「数据库索引」又或者说解决线上慢查询上。
  • 而对于MySQL内部的参数调优,由专业的DBA来搞。

那你来聊聊你们平时开发的规范和索引这块,平时是怎么样的吧。

  • 嗯,首先,我们在生产环境下,创建数据库表,都是在工单系统下完成的(那就自然需要DBA审批)

  • 如果在创建表时检测到没有创建索引,那就会直接提示warning

  • 理论上来说,如果表有一定的数据量,那就应该要创建对应的索引

  • 从数据库查询数据需要注意的地方还是蛮多的,其中很多都是平时积累来的比如说:

    • 1.是否能使用「覆盖索引」,减少「回表」所消耗的时间。意味着,我们在select的时候,一定要指明对应的列,而不是select
    • 2.考虑是否组建「联合索引小」,如果组建「联合索引小」,尽量将区分度最高的放在最左边,并且需要考虑「最左匹配原则」
    • 3.对索引进行函数操作或者表达式计算会导致索引失效
    • 4.利用子查询优化超多分页场景。比如limit offset,n在MySQL是获取offset+n的记录,再返回n条。而利用子查询则是查出n条,通过ID检索对应的记录出来,提高查询效率。
    • 5.通过explain命令来查看SQL的执行计划,看看自己写的SQL是否走了索引,走了什么索引。通过show profile来查看SQL对系统资源的损耗情况(不过一般还是比较少用到的)
    • 6.在开启事务后,在事务内尽可能只操作数据库,并有意识地减少锁的持有时间(比如在事务内需要插入&&修改数据,那可以先插入后修改。因为修改是更新操作,会加行锁。如果先更新,那并发下可能会导致多个事务的请求等待行锁释放)

嗯,你提到了事务,之前也讲过了事务的隔离级别嘛,那你线上用的是什么隔离级别?

  • 嗯,我们这边用的是Read Commit(读已提交),MySQL默认用的是Repeatable read(可重复读)
  • 选用什么隔离级别,主要看应用场景嘛,因为隔离级别越低,事务并发性能越高。
  • (一般互联网公司都选择Read Commit作为主要的隔离级别)
  • 像Repeatable read(可重复读)隔离级别,就有可能因为「间隙锁」导致的死锁问题。
  • 但,MySQL默认的隔离级别为Repeatable read。很大一部分原因是在最开始的时候,MySQL的binlog没有row模式,在read commit隔离级别下会存在「主从数据不一致」的问题
  • binlog记录了数据库表结构和表数据「变更」,比如update/delete/insert/truncate/create。在MySQL中,主从同步实际上就是应用了binlog:来实现的
  • 有了该历史原因,所以MySQL就将默认的隔离级别设置为Repeatable read

了解了,那我顺便想问下,你们遇到过类似的问题吗:即便走对了索引,线上查询还是慢。

  • 如果走对了索引,但查询还是慢,那一般来说就是表的数据量实在是太大了。
  • 首先,考虑能不能把「旧的数据」给”删掉”,对于我们公司而言,我们都会把数据同步到Hive,说明已经离线存储了一份了。
  • 那如果「旧的数据」已经没有查询的业务了,那最简单的办法肯定是”删掉”部分数据咯。数据量降低了,那自然,检索速度就快了…
  • 但,只有极少部分业务可以删掉数据
  • 随后,就考虑另一种情况,能不能在查询数据库之前,直接走一层缓存(Redis)。
    • 而走缓存的话,又要看业务能不能忍受读取的「非真正实时」的数据(毕竟Redis和MySQL的数据一致性需要保证),如果查询条件相对复杂且多变的话(涉及各种group by和sum),那走缓存也不是一种好的办法,维护起来就不方便了…
    • 再看看是不是有「字符串」检索的场景导致查询低效,如果是的话,可以考虑把表的数据导入至Elasticsearch类的搜索引擎,后续的线上查询就直接走Elasticsearch了。
    • MySQL->Elasticsearch需要有对应的同步程序(一般就是监听MySQL的binlog,解析binlog.后导入到Elasticsearch)
    • 如果还不是的话,那考虑要不要根据查询条件的维度,做相对应的聚合表,线上的请求就查询聚合表的数据,不走原表。
      • 比如,用户下单后,有一份订单明细,而订单明细表的量级太大。但在产品侧(前台)透出的查询功能是以「天」维度来展示的,那就可以将每个用户的每天数据聚合起来,在聚合表就是一个用户一天只有一条汇总后的数据。
      • 查询走聚合后的表,那速度肯定杠杠的(聚合后的表数据量肯定比原始表要少很多)
      • 思路大致的就是「以空间换时间」,相同的数据换别的地方也存储一份,提高查询效率

那我还想问下,除了读之外,写性能同样有瓶颈,怎么办?

  • 如果在MySQL读写都有瓶颈,那首先看下目前MySQL的架构是怎么样的。
  • 如果是单库的,那是不是可以考虑升级至主从架构,实现读写分离。
    • 简单理解就是:主库接收写请求,从库接收读请求。从库的数据由主库发送的binlog进而更新,实现主从数据一致(在一般场景下,主从的数据是通过异步来保证最终一致性的)
  • 如果在主从架构下,读写仍存在瓶颈,那就要考虑是否要分库分表了
    • 至少在我前公司的架构下,业务是区分的。流量有流量数据库,广告有广告的数据库,商品有商品的数据库
    • 所以,我这里讲的分库分表的含义是:在原来的某个库的某个表进而拆分。
      • 比如,现在我有一张业务订单表,这张订单表在广告库中,假定这张业务订单表已经有1亿数据量了,现在我要分库分表
        • 那就会将这张表的数据分至多个广告库以及多张表中
        • 分库分表的最明显的好处就是把请求进行均摊(本来单个库单个表有一亿的数据,那假设我分开8个库,那每个库1200+W的数据量,每个库下分8张表,那每张表就150W的数据量)。

你们是以什么来作为分库分表键的?

  • 按照我们这边的经验,一般来说是按照userld的(因为按照用户的维度查询比较多),如果要按照其他的维度进行查询,那还是参照上面的的思路(以空间换时间)。

那分库分表后的D是怎么生成的?

  • 这就涉及到分布式D生成的方式了,思路有很多。有借助MySQL自增的,有借助Redis自增的,有基于「雪花算法」自增的
  • 具体使用哪种方式,那就看公司的技术栈了,一般使用Redis和基于「雪花算法」实现用得比较多。
  • 至于为什么强调自增(还是跟索引是有序有关,前面已经讲过了,你应该还记得)

嗯,那如果我要分库分表了,迁移的过程是怎么样的呢

  • 我们一般采取「双写」的方式来进行迁移,大致步骤就是:
    • 1.增量的消息各自往新表和旧表写一份
    • 2.将旧表的数据迁移至新库
    • 3.迟早新表的数据都会追得上旧表(在某个节点上数据是同步的)
    • 4.校验新表和老表的数据是否正常(主要看能不能对得上)
    • 5.开启双读(一部分流量走新表,一部分流量走老表),相当于灰度上线的过程
    • 6.读流量全部切新表,停止老表的写入
  • 另外,提前准备回滚机制,临时切换失败能恢复正常业务以及有修数据的相关程序。

总结

  • 数据库表存在一定数据量,就需要有对应的索引
  • 发现慢查询时,检查是否走对索引,是否能用更好的索引进行优化查询速度,查看使用索引的姿势有没有问题
  • 当索引解决不了慢查询时,一般由于业务表的数据量太大导致,利用空间换时间的思想(NOSQL、聚合、冗余…)
  • 当读写性能均遇到瓶颈时,先考虑能否升级数据库架构即可解决问题,若不能则需要考虑分库分表
  • 分库分表虽然能解决掉读写瓶颈,但同时会带来各种问题,需要提前调研解决方案和踩坑
l