Java框架篇

Spring

概念

Spring是一个轻量级的IoC和AOP容器框架。是为Java应用程序提供基础***的一套框架,目的是用于简化企业应用程序的开发,它使得开发者只需要关心业务需求。主要包括以下七个模块:

  • Spring Context:提供框架式的Bean访问方式,以及企业级功能(JNDI、定时任务等);
  • Spring Core:核心类库,所有功能都依赖于该类库,提供IOC和DI服务;
  • Spring AOP:AOP服务;
  • Spring Web:提供了基本的面向Web的综合特性;
  • Spring MVC:提供面向Web应用的Model-View-Controller,即MVC实现。
  • Spring DAO:对JDBC的抽象封装,简化了数据访问异常的处理,并能统一管理JDBC事务;
  • Spring ORM:对现有的ORM框架的支持(如MyBatis);

优点

  • 轻量级、非侵入
  • IOC容器管理
  • DI机制将对象之间的依赖关系交由框架处理,降低组件的耦合性
  • 提供了AOP技术,将一些通用任务进行集中管理,从而提供更好的复用。
  • 方便集成
  • 丰富的功能

注解

  • @Component :标准一个普通的spring Bean类。
  • @Repository:标注一个DAO组件类。
  • @Service:标注一个业务逻辑组件类。
  • @Controller:标注一个控制器组件类
  • @Autowired:可用于为类的属性、构造器、方法进行注值
  • @Resource:为目标bean指定协作者Bean。

(1):相同点 @Resource的作用相当于@Autowired,均可标注在字段或属性的setter方法上。

(2):不同点

a:提供方 @Autowired是Spring的注解,@Resource是javax.annotation注解,而是来自于JSR-250,J2EE提供,需要JDK1.6及以上。
b :注入方式 @Autowired只按照Type 注入;@Resource默认按Name自动注入,也提供按照Type 注入;
c:属性
@Autowired注解可用于为类的属性、构造器、方法进行注值。默认情况下,其依赖的对象必须存在(bean可用),如果需要改变这种默认方式,可以设置其required属性为false。
还有一个比较重要的点就是,@Autowired注解默认按照类型装配,如果容器中包含多个同一类型的Bean,那么启动容器时会报找不到指定类型bean的异常,解决办法是结合@Qualifier注解进行限定,指定注入的bean名称。
@Resource有两个中重要的属性:name和type。name属性指定byName,如果没有指定name属性,当注解标注在字段上,即默认取字段的名称作为bean名称寻找依赖对象,当注解标注在属性的setter方法上,即默认取属性名作为bean名称寻找依赖对象。
需要注意的是,@Resource如果没有指定name属性,并且按照默认的名称仍然找不到依赖对象时, @Resource注解会回退到按类型装配。但一旦指定了name属性,就只能按名称装配了。

d:@Resource注解的使用性更为灵活,可指定名称,也可以指定类型 ;@Autowired注解进行装配容易抛出异常,特别是装配的bean类型有多个的时候,而解决的办法是需要在增加@Qualifier进行限定。

IOC

IoC(Inverse of Control:控制反转)是⼀种设计思想,就是将原本在程序中⼿动创建对象的控制权,交由Spring框架来管理。 IoC 容器是Spring ⽤来实现 IoC 的载体, IoC 容器实际上就是个Map(key,value),Map 中存放的是各种对象。

以前创建对象的主动权和时机都是由自己把控的,IOC让对象的创建不用去new了,可以由spring自动生产,使用java的反射机制,根据配置文件在运行时动态的去创建对象以及管理对象,并调用对象的方法的。IoC 容器就像是⼀个⼯⼚⼀样,当我们需要创建⼀个对象的时候,只需要配置好配置⽂件/注解即可,完全不⽤考虑对象是如何被创建出来的。

Spring的IOC有三种注入方式

  • 构造器注入
    • 没有部分注入
    • 不会覆盖setter属性
    • 任何修改都会创建一个新实例
    • 适用于设置很多属性
  • setter方法注入
    • 可以部分注入
    • 会覆盖setter属性
    • 任意修改不会创建一个新实例
    • 适用于设置少量属性
  • 接口注入

AOP

AOP一般称为面向切面,是面向对象的一种补充,并将那些影响了多个类的公共行为封装到一个可重用模块,并将其命名为"Aspect",即切面,减少系统中的重复代码,降低了模块间的耦合度,提高系统的可维护性。可用于权限认证、日志、事务处理。

AOP实现的关键在于 代理模式,AOP代理主要分为静态代理和动态代理。静态代理的代表为AspectJ;动态代理则以Spring AOP为代表。

(1)AspectJ是静态代理,也称为编译时增强,AOP会在编译阶段生成AOP代理类,并将AspectJ(切面)织入到Java字节码中,运行的时候就是增强之后的AOP对象。

(2)Spring AOP使用的动态代理,所谓的动态代理就是说AOP框架不会去修改字节码,而是每次运行时在内存中临时为方法生成一个AOP对象,这个AOP对象包含了目标对象的全部方法,并且在特定的切点做了增强处理,并回调原对象的方法。

Spring AOP中的动态代理主要有两种方式,JDK动态代理和CGLIB动态代理:

​ ① JDK动态代理只提供接口的代理,不支持类的代理,要求被代理类实现接口。JDK动态代理的核心是InvocationHandler接口和Proxy类,在获取代理对象时,使用Proxy类来动态创建目标类的代理类(即最终真正的代理类,这个类继承自Proxy并实现了我们定义的接口),当代理对象调用真实对象的方法时, InvocationHandler 通过invoke()方法反射来调用目标类中的代码,动态地将横切逻辑和业务编织在一起;

InvocationHandler 的 invoke(Object proxy,Method method,Object[] args):proxy是最终生成的代理对象; method 是被代理目标实例的某个具体方法; args 是被代理目标实例某个方法的具体入参, 在方法反射调用时使用。

​ ② 如果被代理类没有实现接口,那么Spring AOP会选择使用CGLIB来动态代理目标类。CGLIB(Code Generation Library),是一个代码生成的类库,可以在运行时动态的生成指定类的一个子类对象,并覆盖其中特定方法并添加增强代码,从而实现AOP。CGLIB是通过继承的方式做的动态代理,因此如果某个类被标记为final,那么它是无法使用CGLIB做动态代理的。

(3)静态代理与动态代理区别在于生成AOP代理对象的时机不同,相对来说AspectJ的静态代理方式具有更好的性能,但是AspectJ需要特定的编译器进行处理,而Spring AOP则无需特定的编译器处理。

AOP 名词解释

(1)连接点(Join point):指程序运行过程中所执行的方法。在Spring AOP中,一个连接点总代表一个方法的执行。

(2)切面(Aspect):被抽取出来的公共模块,可以用来会横切多个对象。Aspect切面可以看成 Pointcut切点 和 Advice通知 的结合,一个切面可以由多个切点和通知组成。

在Spring AOP中,切面可以在类上使用 @AspectJ 注解来实现。

(3)切点(Pointcut):切点用于定义 要对哪些Join point进行拦截。

切点分为execution方式和annotation方式。execution方式可以用路径表达式指定对哪些方法拦截,比如指定拦截add、search。annotation方式可以指定被哪些注解修饰的代码进行拦截。

(4)通知(Advice):指要在连接点(Join Point)上执行的动作,即增强的逻辑,比如权限校验和、日志记录等。通知有各种类型,包括Around、Before、After、After returning、After throwing。

(5)目标对象(Target):包含连接点的对象,也称作被通知(Advice)的对象。 由于Spring AOP是通过动态代理实现的,所以这个对象永远是一个代理对象。

(6)织入(Weaving):通过动态代理,在目标对象(Target)的方法(即连接点Join point)中执行增强逻辑(Advice)的过程。

(7)引入(Introduction):添加额外的方法或者字段到被通知的类。Spring允许引入新的接口(以及对应的实现)到任何被代理的对象。例如,你可以使用一个引入来使bean实现 IsModified 接口,以便简化缓存机制。

通知的类型

(1)前置通知(Before Advice):在连接点(Join point)之前执行的通知。

(2)后置通知(After Advice):当连接点退出的时候执行的通知(不论是正常返回还是异常退出)。

(3)环绕通知(Around Advice):包围一个连接点的通知,这是最强大的一种通知类型。 环绕通知可以在方法调用前后完成自定义的行为。它也可以选择是否继续执行连接点或直接返回它们自己的返回值或抛出异常来结束执行。

(4)返回后通知(AfterReturning Advice):在连接点正常完成后执行的通知(如果连接点抛出异常,则不执行)

(5)抛出异常后通知(AfterThrowing advice):在方法抛出异常退出时执行的通知

Spring的启动流程

(1)初始化Spring容器,注册内置的BeanPostProcessor的BeanDefinition到容器中:

  • ① 实例化BeanFactory【DefaultListableBeanFactory】工厂,用于生成Bean对象
  • ② 实例化BeanDefinitionReader注解配置读取器,用于对特定注解(如@Service、@Repository)的类进行读取转化成 BeanDefinition 对象,(BeanDefinition 是 Spring 中极其重要的一个概念,它存储了 bean 对象的所有特征信息,如是否单例,是否懒加载,factoryBeanName 等)
  • ③ 实例化ClassPathBeanDefinitionScanner路径扫描器,用于对指定的包目录进行扫描查找 bean 对象

(2)将配置类的BeanDefinition注册到容器中:

(3)调用refresh()方法刷新容器:

SpringBean

Spring Bean的生命周期

简单来说,Spring Bean的生命周期只有四个阶段:实例化 Instantiation --> 属性赋值 Populate --> 初始化 Initialization --> 销毁 Destruction,详细来说,如下:

1)实例化Bean:

对于BeanFactory容器,当客户向容器请求一个尚未初始化的bean时,或初始化bean的时候需要注入另一个尚未初始化的依赖时,容器就会调用createBean进行实例化。

对于ApplicationContext容器,当容器启动结束后,通过获取BeanDefinition对象中的信息,实例化所有的bean。

(2)设置对象属性(依赖注入):实例化后的对象被封装在BeanWrapper对象中,紧接着,Spring根据BeanDefinition中的信息 以及 通过BeanWrapper提供的设置属性的接口完成属性设置与依赖注入。

(3)处理Aware接口:Spring会检测该对象是否实现了xxxAware接口,通过Aware类型的接口,可以让我们拿到Spring容器的一些资源:

  • ①如果这个Bean实现了BeanNameAware接口,会调用它实现的setBeanName(String beanId)方法,传入Bean的名字;
  • ②如果这个Bean实现了BeanClassLoaderAware接口,调用setBeanClassLoader()方法,传入ClassLoader对象的实例。
  • ②如果这个Bean实现了BeanFactoryAware接口,会调用它实现的setBeanFactory()方法,传递的是Spring工厂自身。
  • ③如果这个Bean实现了ApplicationContextAware接口,会调用setApplicationContext(ApplicationContext)方法,传入Spring上下文;

(4)BeanPostProcessor前置处理:如果想对Bean进行一些自定义的前置处理,那么可以让Bean实现了BeanPostProcessor接口,那将会调用postProcessBeforeInitialization(Object obj, String s)方法。

(5)InitializingBean:如果Bean实现了InitializingBean接口,执行afeterPropertiesSet()方法。

(6)init-method:如果Bean在Spring配置文件中配置了 init-method 属性,则会自动调用其配置的初始化方法。

(7)BeanPostProcessor后置处理:如果这个Bean实现了BeanPostProcessor接口,将会调用postProcessAfterInitialization(Object obj, String s)方法;由于这个方法是在Bean初始化结束时调用的,所以可以被应用于内存或缓存技术;

以上几个步骤完成后,Bean就已经被正确创建了,之后就可以使用这个Bean了。

(8)DisposableBean:当Bean不再需要时,会经过清理阶段,如果Bean实现了DisposableBean这个接口,会调用其实现的destroy()方法;

(9)destroy-method:最后,如果这个Bean的Spring配置中配置了destroy-method属性,会自动调用其配置的销毁方法。

Spring中bean的作用域

(1)singleton:默认作用域,单例bean,每个容器中只有一个bean的实例。

(2)prototype:为每一个bean请求创建一个实例。

(3)request:为每一个request请求创建一个实例,在请求完成以后,bean会失效并被垃圾回收器回收。

(4)session:与request范围类似,同一个session会话共享一个实例,不同会话使用不同的实例。

(5)global-session:全局作用域,所有会话共享一个实例。如果想要声明让所有会话共享的存储变量的话,那么这全局变量需要存储在global-session中。

spring解决循环依赖

Spring的循环依赖有两种场景

  1. 构造器的循环依赖
  2. 属性的循环依赖

构造器的循环依赖,可以在构造函数中使用@Lazy注解延迟加载。在注入依赖时,先注入代理对象,当首次使用时再创建对象完成注入。

1、一级缓存:Map<String, Object> singletonObjects:

(1)第一级缓存的作用:

  • 用于存储单例模式下创建的Bean实例(已经创建完毕)。
  • 该缓存是对外使用的,指的就是使用Spring框架的程序员。

(2)存储什么数据?

  • K:bean的名称
  • V:bean的实例对象(有代理对象则指的是代理对象,已经创建完毕)

2、第二级缓存:Map<String, Object> earlySingletonObjects:

(1)第二级缓存的作用:

  • 用于存储单例模式下创建的Bean实例(该Bean被提前暴露的引用,该Bean还在创建中)。
  • 该缓存是对内使用的,指的就是Spring框架内部逻辑使用该缓存。
  • 为了解决第一个classA引用最终如何替换为代理对象的问题(如果有代理对象)

3、第三级缓存:Map<String, ObjectFactory<?>> singletonFactories:

(1)第三级缓存的作用:

  • 通过ObjectFactory对象来存储单例模式下提前暴露的Bean实例的引用(正在创建中)。
  • 该缓存是对内使用的,指的就是Spring框架内部逻辑使用该缓存。
  • 此缓存是解决循环依赖最大的功臣

(2)存储什么数据?

  • K:bean的名称
  • V:ObjectFactory,该对象持有提前暴露的bean的引用

(3)为什么第三级缓存要使用ObjectFactory?

如果仅仅是解决循环依赖问题,使用二级缓存就可以了,但是如果对象实现了AOP,那么注入到其他bean的时候,并不是最终的代理对象,而是原始的。这时就需要通过三级缓存的ObjectFactory才能提前产生最终的需要代理的对象。

(4)什么时候将Bean的引用提前暴露给第三级缓存的ObjectFactory持有?时机就是在第一步实例化之后,第二步依赖注入之前,完成此操作。

spring的自动装配

在spring中,使用autowire来配置自动装载模式,对象无需自己查找或创建与其关联的其他对象,由容器负责把需要相互协作的对象引用赋予各个对象。

(1)在Spring框架xml配置共有5种自动装配:

  • no:默认的方式是不进行自动装配的,通过手工设置ref属性来进行装配bean。
  • byName:通过bean的名称进行自动装配,如果一个bean的 property 与另一bean 的name 相同,就进行自动装配。
  • byType:通过参数的数据类型进行自动装配。
  • constructor:利用构造函数进行装配,并且构造函数的参数通过byType进行装配。
  • autodetect:自动探测,如果有构造方法,通过 construct的方式自动装配,否则使用 byType的方式自动装配。

(2)基于注解的自动装配方式:

使用@Autowired、@Resource注解来自动装配指定的bean。在使用@Autowired注解之前需要在Spring配置文件进行配置,<context:annotation-config />。在启动spring IoC时,容器自动装载了一个AutowiredAnnotationBeanPostProcessor后置处理器,当容器扫描到@Autowied、@Resource或@Inject时,就会在IoC容器自动查找需要的bean,并装配给该对象的属性。在使用@Autowired时,首先在容器中查询对应类型的bean:

如果查询结果刚好为一个,就将该bean装配给@Autowired指定的数据;

如果查询的结果不止一个,那么@Autowired会根据名称来查找;

如果上述查找的结果为空,那么会抛出异常。解决方法时,使用required=false。

@Autowired可用于:构造函数、成员变量、Setter方法

@Autowired和@Resource之间的区别:

@Autowired默认是按照类型装配注入的,默认情况下它要求依赖对象必须存在(可以设置它required属性为false)。

@Resource默认是按照名称来装配注入的,只有当找不到与名称匹配的bean才会按照类型来装配注入。

spring事务

Spring事务的本质其实就是数据库对事务的支持,没有数据库的事务支持,spring是无法提供事务功能的。Spring只提供统一事务管理接口,具体实现都是由各数据库自己实现,数据库事务的提交和回滚是通过binlog或者undo log实现的。Spring会在事务开始时,根据当前环境中设置的隔离级别,调整数据库隔离级别,由此保持一致。

(1)Spring事务的种类:

spring支持编程式事务管理和声明式事务管理两种方式:

①编程式事务管理使用TransactionTemplate。

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

声明式事务最大的优点就是不需要在业务逻辑代码中掺杂事务管理的代码,只需在配置文件中做相关的事务规则声明或通过@Transactional注解的方式,便可以将事务规则应用到业务逻辑中,减少业务代码的污染。唯一不足地方是,最细粒度只能作用到方法级别,无法做到像编程式事务那样可以作用到代码块级别。

(2)spring的事务传播机制:

spring事务的传播机制说的是,当多个事务同时存在的时候,spring如何处理这些事务的行为。事务传播机制实际上是使用简单的ThreadLocal实现的,所以,如果调用的方法是在新线程调用的,事务传播实际上是会失效的。

① PROPAGATION_REQUIRED:(默认传播行为)如果当前没有事务,就创建一个新事务;如果当前存在事务,就加入该事务。

② PROPAGATION_REQUIRES_NEW:无论当前存不存在事务,都创建新事务进行执行。

③ PROPAGATION_SUPPORTS:如果当前存在事务,就加入该事务;如果当前不存在事务,就以非事务执行。‘

④ PROPAGATION_NOT_SUPPORTED:以非事务方式执行操作,如果当前存在事务,就把当前事务挂起。

⑤ PROPAGATION_NESTED:如果当前存在事务,则在嵌套事务内执行;如果当前没有事务,则按REQUIRED属性执行。

⑥ PROPAGATION_MANDATORY:如果当前存在事务,就加入该事务;如果当前不存在事务,就抛出异常。

⑦ PROPAGATION_NEVER:以非事务方式执行,如果当前存在事务,则抛出异常。

(3)Spring中的隔离级别:

① ISOLATION_DEFAULT:这是个 PlatfromTransactionManager 默认的隔离级别,使用数据库默认的事务隔离级别。

② ISOLATION_READ_UNCOMMITTED:读未提交,允许事务在执行过程中,读取其他事务未提交的数据。

③ ISOLATION_READ_COMMITTED:读已提交,允许事务在执行过程中,读取其他事务已经提交的数据。

④ ISOLATION_REPEATABLE_READ:可重复读,在同一个事务内,任意时刻的查询结果都是一致的。

⑤ ISOLATION_SERIALIZABLE:所有事务逐个依次执行。

Spring 框架中都用到了哪些设计模式

(1)工厂模式:Spring使用工厂模式,通过BeanFactory和ApplicationContext来创建对象

(2)单例模式:Bean默认为单例模式

(3)策略模式:例如Resource的实现类,针对不同的资源文件,实现了不同方式的资源获取策略

(4)代理模式:Spring的AOP功能用到了JDK的动态代理和CGLIB字节码生成技术

(5)模板方法:可以将相同部分的代码放在父类中,而将不同的代码放入不同的子类中,用来解决代码重复的问题。比如RestTemplate, JmsTemplate, JpaTemplate

(6)适配器模式:Spring AOP的增强或通知(Advice)使用到了适配器模式,Spring MVC中也是用到了适配器模式适配Controller

(7)观察者模式:Spring事件驱动模型就是观察者模式的一个经典应用。

(8)桥接模式:可以根据客户的需求能够动态切换不同的数据源。比如我们的项目需要连接多个数据库,客户在每次访问中根据需要会去访问不同的数据库

SpringMVC

概念

Spring MVC是一个基于Java的实现了MVC设计模式的请求驱动类型的轻量级Web框架,通过把Model,View,Controller分离,将web层进行职责解耦,把复杂的web应用分成逻辑清晰的几部分,简化开发,减少出错,方便组内开发人员之间的配合。

优点

(1)可以支持各种视图技术,而不仅仅局限于JSP;

(2)与Spring框架集成(如IoC容器、AOP等);

(3)清晰的角色分配:前端控制器(dispatcherServlet) ,请求到处理器映射(handlerMapping),处理器适配器(HandlerAdapter),视图解析器(ViewResolver)。

(4) 支持各种请求资源的映射策略。

流程

  • (1)用户发送请求至前端控制器DispatcherServlet;
  • (2)DispatcherServlet收到请求后,调用HandlerMapping处理器映射器,请求获取Handler;
  • (3)处理器映射器根据请求url找到具体的处理器Handler,生成处理器对象及处理器拦截器(如果有则生成),一并返回给DispatcherServlet;
  • (4)DispatcherServlet 调用 HandlerAdapter处理器适配器,请求执行Handler;
  • (5)HandlerAdapter 经过适配调用 具体处理器进行处理业务逻辑;
  • (6)Handler执行完成返回ModelAndView;
  • (7)HandlerAdapter将Handler执行结果ModelAndView返回给DispatcherServlet;
  • (8)DispatcherServlet将ModelAndView传给ViewResolver视图解析器进行解析;
  • (9)ViewResolver解析后返回具体View;
  • (10)DispatcherServlet对View进行渲染视图(即将模型数据填充至视图中)
  • (11)DispatcherServlet响应用户。
  • 前端控制器 DispatcherServlet:接收请求、响应结果,相当于转发器,有了DispatcherServlet 就减少了其它组件之间的耦合度。
  • 处理器映射器 HandlerMapping:根据请求的URL来查找Handler
  • 处理器适配器 HandlerAdapter:负责执行Handler
  • 处理器 Handler:处理器,需要程序员开发
  • 视图解析器 ViewResolver:进行视图的解析,根据视图逻辑名将ModelAndView解析成真正的视图(view)
  • 视图View:View是一个接口, 它的实现类支持不同的视图类型,如jsp,freemarker,pdf等等

注解

  • @Controller :表明该类会作为与前端作交互的控制层组件,通过服务接口定义的提供访问应用程序的一种行为,解释用户的输入,将其转换成一个模型然后将试图呈献给用户。
  • @RequestMapping : 这个注解用于将url映射到整个处理类或者特定的处理请求的方法。可以只用通配符!既可以作用在类级别,也可以作用在方法级别。可以使用 method 属性标记其所接受的方法类型,如果不指定方法类型的话,可以使用 HTTP GET/POST 方法请求数据,但是一旦指定方法类型,就只能使用该类型获取数据。
  • @RequestParam :将请求的参数绑定到方法中的参数上,有required参数,默认情况下,required=true,也就是改参数必须要传。如果改参数可以传可不传,可以配置required=false。
  • @PathVariable : 该注解用于方法修饰方法参数,会将修饰的方法参数变为可供使用的uri变量(可用于动态绑定)。支持使用正则表达式.
  • @RequestBody : 是指方法参数应该被绑定到HTTP请求Body上。
  • @ResponseBody :它的作用是将返回类型直接输入到HTTP response body中。在输出JSON格式的数据时,会经常用到。

解决中文乱码

(1)解决post请求乱码问题:在web.xml中配置一个CharacterEncodingFilter过滤器,设置成utf-8;

<filter>
    <filter-name>CharacterEncodingFilter</filter-name>
    <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
    <init-param>
        <param-name>encoding</param-name>
        <param-value>utf-8</param-value>
    </init-param>
</filter>

<filter-mapping>
    <filter-name>CharacterEncodingFilter</filter-name>
    <url-pattern>/*</url-pattern>
</filter-mapping>

(2)get请求中文参数出现乱码解决方法有两个:

①修改tomcat配置文件添加编码与工程编码一致,如下:

<ConnectorURIEncoding="utf-8" connectionTimeout="20000" port="8080" protocol="HTTP/1.1" redirectPort="8443"/>

②另外一种方法对参数进行重新编码:

String userName = new String(request.getParamter("userName").getBytes("ISO8859-1"),"utf-8")

Mybatis

概念

1)Mybatis是一个半ORM(对象关系映射)框架,它内部封装了JDBC,加载驱动、创建连接、创建statement等繁杂的过程,开发者开发时只需要关注如何编写SQL语句,可以严格控制sql执行性能,灵活度高。

(2)作为一个半ORM框架,MyBatis 可以使用 XML 或注解来配置和映射原生信息,将 POJO映射成数据库中的记录,避免了几乎所有的 JDBC 代码和手动设置参数以及获取结果集。

称Mybatis是半自动ORM映射工具,是因为在查询关联对象或关联集合对象时,需要手动编写sql来完成。不像Hibernate这种全自动ORM映射工具,Hibernate查询关联对象或者关联集合对象时,可以根据对象关系模型直接获取。

(3)通过xml 文件或注解的方式将要执行的各种 statement 配置起来,并通过java对象和 statement中sql的动态参数进行映射生成最终执行的sql语句,最后由mybatis框架执行sql并将结果映射为java对象并返回。(从执行sql到返回result的过程)。

(4)由于MyBatis专注于SQL本身,灵活度高,所以比较适合对性能的要求很高,或者需求变化较多的项目,如互联网项目。

优缺点

(1)优点:

① 基于SQL语句编程,相当灵活,不会对应用程序或者数据库的现有设计造成任何影响,SQL写在XML里,解除sql与程序代码的耦合,便于统一管理;提供XML标签,支持编写动态SQL语句,并可重用。

② 与JDBC相比,减少了50%以上的代码量,消除了JDBC大量冗余的代码,不需要手动开关连接;

③ 很好的与各种数据库兼容(因为MyBatis使用JDBC来连接数据库,所以只要JDBC支持的数据库MyBatis都支持)。

④ 能够与Spring很好的集成;

⑤ 提供映射标签,支持对象与数据库的ORM字段关系映射;提供对象关系映射标签,支持对象关系组件维护。

(2)缺点:

① SQL语句的编写工作量较大,尤其当字段多、关联表多时,对开发人员编写SQL语句的功底有一定要求。

② SQL语句依赖于数据库,导致数据库移植性差,不能随意更换数据库。

#{}和${}的区别

${}是字符串替换,#{}是预处理;

Mybatis在处理{}直接替换成变量的值。而Mybatis在处理#{}时,会对sql语句进行预处理,将sql中的#{}替换为?号,调用PreparedStatement的set方法来赋值;

使用#{}可以有效的防止SQL注入,提高系统安全性。

mapper接口

工作原理

Mapper 接口的工作原理是JDK动态代理,Mybatis运行时会使用JDK动态代理为Mapper接口生成代理对象proxy,代理对象会拦截接口方法,根据类的全限定名+方法名,唯一定位到一个MapperStatement并调用执行器执行所代表的sql,然后将sql执行结果返回。

Mapper接口里的方法,是不能重载的,因为是使用 全限名+方法名 的保存和寻找策略。

Dao接口即Mapper接口。接口的全限名,就是映射文件中的namespace的值;接口的方法名,就是映射文件中Mapper的Statement的id值;接口方法内的参数,就是传递给sql的参数。

当调用接口方法时,接口全限名+方法名拼接字符串作为key值,可唯一定位一个MapperStatement。在Mybatis中,每一个SQL标签,比如<select>、<insert>、<update>、<delete>标签,都会被解析为一个MapperStatement对象。</delete></update></insert></select>

Dao接口和XML文件的SQL如何建立关联

一、解析XML:

首先,Mybatis在初始化 SqlSessionFactoryBean的时候,找到 mapperLocations路径去解析里面所有的XML文件.

1、创建SqlSource:

Mybatis会把每个SQL标签封装成SqlSource对象。然后根据SQL语句的不同,又分为动态SQL和静态SQL。其中,静态SQL包含一段String类型的sql语句;而动态SQL则是由一个个SqlNode组成。

2、创建MappedStatement:

ML文件中的每一个SQL标签就对应一个MappedStatement对象,这里面有两个属性很重要。

① id:全限定类名+方法名组成的ID。

② sqlSource:当前SQL标签对应的SqlSource对象。

创建完 MappedStatement对象,将它缓存到 Configuration 中。Configuration对象就是Mybatis中的大管家,基本所有的配置信息都维护在这里。把所有的XML都解析完成之后,Configuration就包含了所有的SQL信息。

到目前为止,XML就解析完成了。当我们执行Mybatis方法的时候,就通过全限定类名+方法名找到MappedStatement对象,然后解析里面的SQL内容,执行即可。

二、Dao接口代理:

项目是基于SpringBoot的,那么肯定也见过这种: @MapperScan("com.xxx.dao")

它们的作用是一样的。将包路径下的所有类注册到Spring Bean中,并且将它们的beanClass设置为 MapperFactoryBeanMapperFactoryBean实现了 FactoryBean接口,俗称工厂Bean。那么,当我们通过 @Autowired注入这个Dao接口的时候,返回的对象就是MapperFactoryBean这个工厂Bean中的 getObject()方法对象。

那么,这个方法干了些什么呢?

简单来说,它就是通过JDK动态代理,返回了一个Dao接口的代理对象,这个代理对象的处理器是MapperProxy对象。所有,我们通过@Autowired注入Dao接口的时候,注入的就是这个代理对象,我们调用到Dao接口的方法时,则会调用到MapperProxy对象的invoke()方法。

那么,目前为止,我们通过Dao接口也有了代理实现,所以就可以执行到它里面的方法了。

三、执行:

如上所述,当我们调用Dao接口方法的时候,实际调用到代理对象的invoke()方法。 在这里,实际上调用的就是SqlSession里面的东西了。

通过statement(全限定类型+方法名)拿到MappedStatement 对象,然后通过执行器Executor去执行具体SQL并返回。

接口要求

  • Mapper接口方法名和mapper.xml中定义的每个sql的id相同;
  • Mapper接口方法的输入参数类型和mapper.xml中定义的每个sql 的parameterType的类型相同;
  • Mapper接口方法的输出参数类型和mapper.xml中定义的每个sql的resultType的类型相同;
  • Mapper.xml文件中的namespace即是mapper接口的类路径。

分页

Mybatis使用RowBounds对象进行分页,它是针对ResultSet结果集执行的内存分页,而非物理分页。可以在sql内直接书写带有物理分页的参数来完成物理分页功能,也可以使用分页插件来完成物理分页。

​ 分页插件的基本原理是使用Mybatis提供的插件接口,实现自定义插件,在插件的拦截方法内拦截待执行的sql,然后重写sql,添加对应的物理分页语句和物理分页参数。

缓存

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

(2)二级缓存与一级缓存其机制相同,默认也是采用 PerpetualCache,HashMap 存储,不同在于其存储作用域为 Mapper(Namespace),并且可自定义存储源,如 Ehcache。默认不打开二级缓存,要开启二级缓存,使用二级缓存属性类需要实现Serializable序列化接口(可用来保存对象的状态),可在它的映射文件中配置 ;

(3)对于缓存数据更新机制,当某一个作用域(一级缓存 Session/二级缓存Namespaces)的进行了C/U/D 操作后,默认该作用域下所有 select 中的缓存将被 clear 掉并重新更新,如果开启了二级缓存,则只根据配置判断是否刷新。

动态sql

Mybatis动态sql可以在Xml映射文件内,以标签的形式编写动态sql,执行原理是根据表达式的值 完成逻辑判断 并动态拼接sql的功能。

Mybatis提供了9种动态sql标签:trim | where | set | foreach | if | choose | when | otherwise | bind。

常见标签

<resultmap>、<parametermap>、<sql>、<include>、<selectkey>,加上动态sql的9个标签,其中<sql>为sql片段标签,通过<include>标签引入sql片段,<selectkey>为不支持自增的主键生成策略标签。</selectkey></include></sql></selectkey></include></sql></parametermap></resultmap>

传递多个参数

(1)第一种:
//DAO层的函数
Public UserselectUser(String name,String area);
//对应的xml,#{0}代表接收的是dao层中的第一个参数,#{1}代表dao层中第二参数,更多参数一致往后加即可

  1. <select id="selectUser"resultMap="BaseResultMap">

  2. ​ select * fromuser_user_t whereuser_name = #{0} anduser_area=#{1}

(2)第二种: 使用 @param 注解:
public interface usermapper {
user selectuser(@param(“username”) string username,@param(“hashedpassword”) string hashedpassword);
}
然后,就可以在xml像下面这样使用(推荐封装为一个map,作为单个参数传递给mapper):

  1. <select id="selectuser" resulttype="user"> </select>
  2. ​ select id, username, hashedpassword

  3. ​ from some_table

  4. ​ where username = #{username}

  5. ​ and hashedpassword = #{hashedpassword}

(3)第三种:多个参数封装成map
try{
//映射文件的命名空间.SQL片段的ID,就可以调用对应的映射文件中的SQL
//由于我们的参数超过了两个,而方法中只有一个Object参数收集,因此我们使用Map集合来装载我们的参数
Map<String, Object> map = new HashMap();
map.put("start", start);
map.put("end", end);
return sqlSession.selectList("StudentID.pagination", map);
}catch(Exception e){
e.printStackTrace();
sqlSession.rollback();
throw e; }
finally{
MybatisUtil.closeSqlSession();
}

一对一

有联合查询和嵌套查询,联合查询是几个表联合查询,只查询一次, 通过在resultMap里面配置association节点配置一对一的类就可以完成;

嵌套查询是先查一个表,根据这个表里面的结果的 外键id,去再另外一个表里面查询数据,也是通过association配置,但另外一个表的查询通过select属性配置。

一对多

有联合查询和嵌套查询。联合查询是几个表联合查询,只查询一次,通过在resultMap里面的collection节点配置一对多的类就可以完成;嵌套查询是先查一个表,根据这个表里面的 结果的外键id,去再另外一个表里面查询数据,也是通过配置collection,但另外一个表的查询通过select节点配置。

Springboot

概念

Spring Boot 是 Spring 开源组织下的子项目,是 Spring 组件一站式解决方案,主要是简化了使用 Spring 的难度,简省了繁重的配置,提供了各种启动器,开发者能快速上手。

优点

  1. 容易上手,提升开发效率,为 Spring 开发提供一个更快、更广泛的入门体验。
  2. 开箱即用,远离繁琐的配置。
  3. 提供了一系列大型项目通用的非业务性功能,例如:内嵌服务器、安全管理、运行数据监控、运行状况检查和外部化配置等。
  4. 没有代码生成,也不需要XML配置。
  5. 避免大量的 Maven 导入和各种版本冲突。

核心注解

启动类上面的注解是@SpringBootApplication,它也是 Spring Boot 的核心注解,主要组合包含了以下 3 个注解:

@SpringBootConfiguration:组合了 @Configuration 注解,实现配置文件的功能。

@EnableAutoConfiguration:打开自动配置的功能,也可以关闭某个自动配置的选项,如关闭数据源自动配置功能: @SpringBootApplication(exclude = { DataSourceAutoConfiguration.class })。

@ComponentScan:Spring组件扫描。

starter 什么

首先,这个 Starter 并非什么新的技术点,基本上还是基于 Spring 已有功能来实现的。首先它提供了一个自动化配置类,一般命名为 XXXAutoConfiguration ,在这个配置类中通过条件注解来决定一个配置是否生效(条件注解就是 Spring 中原本就有的),然后它还会提供一系列的默认配置,也允许开发者根据实际情况自定义相关配置,然后通过类型安全的属性注入将这些配置属性注入进来,新注入的属性会代替掉默认属性。正因为如此,很多第三方框架,我们只需要引入依赖就可以直接使用了。当然,开发者也可以自定义 Starter

spring-boot-starter-parent 有什么用

我们都知道,新创建一个 Spring Boot 项目,默认都是有 parent 的,这个 parent 就是 spring-boot-starter-parent ,spring-boot-starter-parent 主要有如下作用:

  1. 定义了 Java 编译版本为 1.8 。
  2. 使用 UTF-8 格式编码。
  3. 继承自 spring-boot-dependencies,这个里边定义了依赖的版本,也正是因为继承了这个依赖,所以我们在写依赖时才不需要写版本号。
  4. 执行打包操作的配置。
  5. 自动化的资源过滤。
  6. 自动化的插件配置。
  7. 针对 application.properties 和 application.yml 的资源过滤,包括通过 profile 定义的不同环境的配置文件,例如 application-dev.properties 和 application-dev.yml。

RabbitMQ

优缺点

优点:

(1)解耦:将系统按照不同的业务功能拆分出来,消息生产者只管把消息发布到 MQ 中而不用管谁来取,消息消费者只管从 MQ 中取消息而不管是谁发布的。消息生产者和消费者都不知道对方的存在;

(2)异步:主流程只需要完成业务的核心功能;对于业务非核心功能,将消息放入到消息队列之中进行异步处理,减少请求的等待,提高系统的总体性能;

(3)削峰/限流:将所有请求都写到消息队列中,消费服务器按照自身能够处理的请求数从队列中拿到请求,防止请求并发过高将系统搞崩溃;

缺点:

(1)系统的可用性降低:系统引用的外部依赖越多,越容易挂掉,如果MQ 服务器挂掉,那么可能会导致整套系统崩溃。这时就要考虑如何保证消息队列的高可用了

(2)系统复杂度提高:加入消息队列之后,需要保证消息没有重复消费、如何处理消息丢失的情况、如何保证消息传递的有序性等问题;

(3)数据一致性问题:A 系统处理完了直接返回成功了,使用者都以为你这个请求就成功了;但是问题是,要是 BCD 三个系统那里,BD 两个系统写库成功了,结果 C 系统写库失败了,就会导致数据不一致了

RabbitMQ的构造

RabbitMQ 是 AMQP 协议的一个开源实现,所以其内部实际上也是 AMQP 中的基本概念:

  • (1)生产者Publisher:生产消息,就是投递消息的一方。消息一般包含两个部分:消息体(payload)和标签(Label)
  • (2)消费者Consumer:消费消息,也就是接收消息的一方。消费者连接到RabbitMQ服务器,并订阅到队列上。消费消息时只消费消息体,丢弃标签。
  • (3)Broker服务节点:表示消息队列服务器实体。一般情况下一个Broker可以看做一个RabbitMQ服务器。
  • (4)Queue:消息队列,用来存放消息。一个消息可投入一个或多个队列,多个消费者可以订阅同一队列,这时队列中的消息会被平摊(轮询)给多个消费者进行处理。
  • (5)Exchange:交换器,接受生产者发送的消息,根据路由键将消息路由到绑定的队列上。
  • (6)Routing Key: 路由关键字,用于指定这个消息的路由规则,需要与交换器类型和绑定键(Binding Key)联合使用才能最终生效。
  • (7)Binding:绑定,通过绑定将交换器和队列关联起来,一般会指定一个BindingKey,通过BindingKey,交换器就知道将消息路由给哪个队列了。
  • (8)Connection :网络连接,比如一个TCP连接,用于连接到具体broker
  • (9)Channel: 信道,AMQP 命令都是在信道中进行的,不管是发布消息、订阅队列还是接收消息,这些动作都是通过信道完成。因为建立和销毁 TCP 都是非常昂贵的开销,所以引入了信道的概念,以复用一条 TCP 连接,一个TCP连接可以用多个信道。客户端可以建立多个channel,每个channel表示一个会话任务。
  • (10)Message:消息,由消息头和消息体组成。消息体是不透明的,而消息头则由一系列的可选属性组成,这些属性包括routing-key(路由键)、priority(相对于其他消息的优先权)、delivery-mode(指出该消息可能需要持久性存储)等。
  • (11)Virtual host:虚拟主机,用于逻辑隔离,表示一批独立的交换器、消息队列和相关对象。一个Virtual host可以有若干个Exchange和Queue,同一个Virtual host不能有同名的Exchange或Queue。最重要的是,其拥有独立的权限系统,可以做到 vhost 范围的用户控制。当然,从 RabbitMQ 的全局角度,vhost 可以作为不同权限隔离的手段

Exchange交换器的类型

Exchange分发消息时根据类型的不同分发策略有区别,目前共四种类型:direct、fanout、topic、headers

(1)direct:消息中的路由键(RoutingKey)如果和 Bingding 中的 bindingKey 完全匹配,交换器就将消息发到对应的队列中。是基于完全匹配、单播的模式。

(2)fanout:把所有发送到fanout交换器的消息路由到所有绑定该交换器的队列中,fanout 类型转发消息是最快的。

(3)topic:通过模式匹配的方式对消息进行路由,将路由键和某个模式进行匹配,此时队列需要绑定到一个模式上。

匹配规则:

  • ① RoutingKey 和 BindingKey 为一个 点号 '.' 分隔的字符串。 比如: java.xiaoka.show
  • ② BindingKey可使用 * 和 # 用于做模糊匹配:*匹配一个单词,#匹配多个或者0个单词

(4)headers:不依赖于路由键进行匹配,是根据发送消息内容中的headers属性进行匹配,除此之外 headers 交换器和 direct 交换器完全一致,但性能差很多,目前几乎用不到了

生产者消息的过程

  • (1)Producer 先连接到 Broker,建立连接 Connection,开启一个信道 channel
  • (2)Producer 声明一个交换器并设置好相关属性
  • (3)Producer 声明一个队列并设置好相关属性
  • (4)Producer 通过绑定键将交换器和队列绑定起来
  • (5)Producer 发送消息到 Broker,其中包含路由键、交换器等信息
  • (6)交换器根据接收到的路由键查找匹配的队列
  • (7)如果找到,将消息存入对应的队列,如果没有找到,会根据生产者的配置丢弃或者退回给生产者。
  • (8)关闭信道

消费者接收消息过程

  • (1)Producer 先连接到 Broker,建立连接 Connection,开启一个信道 channel
  • (2)向 Broker 请求消费相应队列中消息,可能会设置响应的回调函数。
  • (3)等待 Broker 回应并投递相应队列中的消息,接收消息。
  • (4)消费者确认收到的消息,ack。
  • (5)RabbitMQ从队列中删除已经确定的消息。
  • (6)关闭信道

如何保证消息不被重复消费

正常情况下,消费者在消费消息后,会给消息队列发送一个确认,消息队列接收后就知道消息已经被成功消费了,然后就从队列中删除该消息,也就不会将该消息再发送给其他消费者了。不同消息队列发出的确认消息形式不同,RabbitMQ是通过发送一个ACK确认消息。但是因为网络故障,消费者发出的确认并没有传到消息队列,导致消息队列不知道该消息已经被消费,然后就再次消息发送给了其他消费者,从而造成重复消费的情况。

重复消费问题的解决思路是:保证消息的唯一性,即使多次传输,也不让消息的多次消费带来影响,也就是保证消息等幂性;幂等性指一个操作执行任意多次所产生的影响均与一次执行的影响相同。具体解决方案如下:

(1)改造业务逻辑,使得在重复消费时也不影响最终的结果。例如对SQL语句: update t1 set money = 150 where id = 1 and money = 100; 做了个前置条件判断,即 money = 100 的情况下才会做更新,更通用的是做个 version 即版本号控制,对比消息中的版本号和数据库中的版本号。

(2)基于数据库的的唯一主键进行约束。消费完消息之后,到数据库中做一个 insert 操作,如果出现重复消费的情况,就会导致主键冲突,避免数据库出现脏数据。

(3)通过记录关键的key,当重复消息过来时,先判断下这个key是否已经被处理过了,如果没处理再进行下一步。

  • ① 通过数据库:比如处理订单时,记录订单ID,在消费前,去数据库中进行查询该记录是否存在,如果存在则直接返回。
  • ② 使用全局唯一ID,再配合第三组主键做消费记录,比如使用 redis 的 set 结构,生产者发送消息时给消息分配一个全局ID,在每次消费者开始消费前,先去redis中查询有没有消费记录,如果消费过则不进行处理,如果没消费过,则进行处理,消费完之后,就将这个ID以k-v的形式存入redis中(过期时间根据具体情况设置)。

如何保证消息不丢失,进行可靠性传输

对于消息的可靠性传输,每种MQ都要从三个角度来分析:生产者丢数据、消息队列丢数据、消费者丢数据。

1、生产者丢数据

RabbitMQ提供事务机制(transaction)和确认机制(confirm)两种模式来确保生产者不丢消息。

(1)事务机制:

发送消息前,开启事务(channel.txSelect()),然后发送消息,如果发送过程中出现什么异常,事务就会回滚(channel.txRollback()),如果发送成功则提交事务(channel.txCommit())

该方式的缺点是生产者发送消息会同步阻塞等待发送结果是成功还是失败,导致生产者发送消息的吞吐量降下降。

    // 开启事务
    channel.txSelect
    try {
        // 发送消息
    } catch(Exception e){
        // 回滚事务
        channel.txRollback;
        //再次重试发送这条消息
        ....
    }      
    //提交事务
    channel.txCommit;

(2)确认机制:

生产环境常用的是confirm模式。生产者将信道 channel 设置成 confirm 模式,一旦 channel 进入 confirm 模式,所有在该信道上发布的消息都将会被指派一个唯一的ID,一旦消息被投递到所有匹配的队列之后,rabbitMQ就会发送一个确认给生产者(包含消息的唯一ID),这样生产者就知道消息已经正确到达目的队列了。如果rabbitMQ没能处理该消息,也会发送一个Nack消息给你,这时就可以进行重试操作。

Confirm模式最大的好处在于它是异步的,一旦发布消息,生产者就可以在等信道返回确认的同时继续发送下一条消息,当消息最终得到确认之后,生产者便可以通过回调方法来处理该确认消息。

处理Ack和Nack的代码如下所示:

channel.addConfirmListener(new ConfirmListener() {  
    @Override  
    public void handleNack(long deliveryTag, boolean multiple) throws IOException {  
        System.out.println("nack: deliveryTag = "+deliveryTag+" multiple: "+multiple);  
    }  
    @Override  
    public void handleAck(long deliveryTag, boolean multiple) throws IOException {  
        System.out.println("ack: deliveryTag = "+deliveryTag+" multiple: "+multiple);  
    }  
}); 

2、消息队列丢数据

处理消息队列丢数据的情况,一般是开启持久化磁盘。持久化配置可以和生产者的 confirm 机制配合使用,在消息持久化磁盘后,再给生产者发送一个Ack信号。这样的话,如果消息持久化磁盘之前,即使rabbitMQ挂掉了,生产者也会因为收不到Ack信号而再次重发消息。

持久化设置如下(必须同时设置以下 2 个配置):

  • (1)创建queue的时候,将queue的持久化标志durable在设置为true,代表是一个持久的队列,这样就可以保证 rabbitmq 持久化 queue 的元数据,但是不会持久化queue里的数据;
  • (2)发送消息的时候将 deliveryMode 设置为 2,将消息设置为持久化的,此时 rabbitmq就会将消息持久化到磁盘上去。

3、消费者丢数据

消费者丢数据一般是因为采用了自动确认消息模式。该模式下,虽然消息还在处理中,但是消费中者会自动发送一个确认,通知rabbitmq已经收到消息了,这时rabbitMQ就会立即将消息删除。这种情况下,如果消费者出现异常而未能处理消息,那就会丢失该消息。解决方案就是采用手动确认消息,等到消息被真正消费之后,再手动发送一个确认信号,即使中途消息没处理完,但是服务器宕机了,那rabbitmq就收不到发的ack,然后 rabbitmq 就会将这条消息重新分配给其他的消费者去处理。

需要注意的是:消息可靠性增强了,性能就下降了,因为写磁盘比写 RAM 慢的多,两者的吞吐量可能有 10 倍的差距。所以,是否要对消息进行持久化,需要综合考虑业务场景、性能需要,以及可能遇到的问题。若想达到单RabbitMQ服务器 10W 条/秒以上的消息吞吐量,则要么使用其他的方式来确保消息的可靠传输,要么使用非常快速的存储系统以支持全持久化,例如***D。或者仅对关键消息作持久化处理,且应该保证关键消息的量不会导致性能瓶颈。

如何保证消息的有序性

针对保证消息有序性的问题,解决方法就是保证生产者入队的顺序是有序的,出队后的顺序消费则交给消费者去保证。

(1)方法一:拆分queue,使得一个queue只对应一个消费者。由于MQ一般都能保证内部队列是先进先出的,所以把需要保持先后顺序的一组消息使用某种算法都分配到同一个消息队列中。然后只用一个消费者单线程去消费该队列,这样就能保证消费者是按照顺序进行消费的了。但是消费者的吞吐量会出现瓶颈。如果多个消费者同时消费一个队列,还是可能会出现顺序错乱的情况,这就相当于是多线程消费了

(2)方法二:对于多线程的消费同一个队列的情况,可以使用重试机制:比如有一个微博业务场景的操作,发微博、写评论、删除微博,这三个异步操作。如果一个消费者先执行了写评论的操作,但是这时微博都还没发,写评论一定是失败的,等一段时间。等另一个消费者,先执行发微博的操作后,再执行,就可以成功。

如何处理消息堆积情况

场景题:几千万条数据在MQ里积压了七八个小时。

1、出现该问题的原因

消息堆积往往是生产者的生产速度与消费者的消费速度不匹配导致的。有可能就是消费者消费能力弱,渐渐地消息就积压了,也有可能是因为消息消费失败反复复重试造成的,也有可能是消费端出了问题,导致不消费了或者消费极其慢。比如,消费端每次消费之后要写mysql,结果mysql挂了,消费端hang住了不动了,或者消费者本地依赖的一个东西挂了,导致消费者挂了。

所以如果是 bug 则处理 bug;如果是因为本身消费能力较弱,则优化消费逻辑,比如优化前是一条一条消息消费处理的,那么就可以批量处理进行优化。

2、临时扩容,快速处理积压的消息:

(1)先修复 consumer 的问题,确保其恢复消费速度,然后将现有的 consumer 都停掉;

(2)临时创建原先 N 倍数量的 queue ,然后写一个临时分发数据的消费者程序,将该程序部署上去消费队列中积压的数据,消费之后不做任何耗时处理,直接均匀轮询写入临时建立好的 N 倍数量的 queue 中;

(3)接着,临时征用 N 倍的机器来部署 consumer,每个 consumer 消费一个临时 queue 的数据

(4)等快速消费完积压数据之后,恢复原先部署架构 ,重新用原先的 consumer 机器消费消息。

这种做法相当于临时将 queue 资源和 consumer 资源扩大 N 倍,以正常 N 倍速度消费。

3、恢复队列中丢失的数据:

如果使用的是 rabbitMQ,并且设置了过期时间,消息在 queue 里积压超过一定的时间会被 rabbitmq 清理掉,导致数据丢失。这种情况下,实际上队列中没有什么消息挤压,而是丢了大量的消息。所以就不能说增加 consumer 消费积压的数据了,这种情况可以采取 “批量重导” 的方案来进行解决。在流量低峰期,写一个程序,手动去查询丢失的那部分数据,然后将消息重新发送到mq里面,把丢失的数据重新补回来。

4、MQ长时间未处理导致MQ写满的情况如何处理:

如果消息积压在MQ里,并且长时间都没处理掉,导致MQ都快写满了,这种情况肯定是临时扩容方案执行太慢,这种时候只好采用 “丢弃+批量重导” 的方式来解决了。首先,临时写个程序,连接到mq里面消费数据,消费一个丢弃一个,快速消费掉积压的消息,降低MQ的压力,然后在流量低峰期时去手动查询重导丢失的这部分数据。

如何保证消息队列的高可用

RabbitMQ 是基于主从(非分布式)做高可用性的,RabbitMQ 有三种模式:单机模式、普通集群模式、镜像集群模式

1、单机模式:一般没人生产用单机模式

2、普通集群模式:

就是在多台机器上启动多个 RabbitMQ 实例,每个机器启动一个。我们创建的 queue,只会放在其中一个 RabbitMQ 实例上,但是每个实例都同步 queue 的元数据(元数据是 queue 的一些配置信息,通过元数据,可以找到 queue 所在实例)。消费的时候,如果连接到了另外一个实例,那么那个实例会从 queue 所在实例上拉取数据过来。

(1)优点:普通集群模式主要用于提高系统的吞吐量,可以通过添加更加的节点来线性的扩展消息队列的吞吐量,就是说让集群中多个节点来服务某个 queue 的读写操作

(2)缺点:无高可用性,queue所在的节点宕机了,其他实例就无法从那个实例拉取数据;RabbitMQ 内部也会产生大量的数据传输。

3、镜像集群模式:

RabbitMQ 真正的高可用模式。镜像集群模式下,队列的元数据和消息会存在于多个实例上,每次写消息到 queue 时,会自动将消息同步到各个实例的 queue ,也就是说每个 RabbitMQ 节点都有这个 queue 的完整镜像,包含 queue 的全部数据。任何一个机器宕机了,其它机器节点还包含了这个 queue 的完整数据,其他 consumer 都可以到其它节点上去消费数据。

配置镜像队列的集群都包含一个主节点master和若干个从节点slave,slave会准确地按照master执行命令的顺序进行动作,故slave与master上维护的状态应该是相同的。如果master由于某种原因失效,那么按照slave加入的时间排序,"资历最老"的slave会被提升为新的master。

除发送消息外的所有动作都只会向master发送,然后再由master将命令执行的结果广播给各个slave。如果消费者与slave建立连接并进行订阅消费,其实质上都是从master上获取消息,只不过看似是从slave上消费而已。比如消费者与slave建立了TCP连接之后执行一个Basic.Get的操作,那么首先是由slave将Basic.Get请求发往master,再由master准备好数据返回给slave,最后由slave投递给消费者。

(1)缺点:

① 性能开销大,消息需要同步到所有机器上,导致网络带宽压力和消耗很重

② 非分布式,没有扩展性,如果 queue 的数据量大到这个机器上的容量无法容纳了,此时该方案就会出现问题了

(2)如何开启镜像集群模式呢?

在RabbitMQ 的管理控制台Admin页面下,新增一个镜像集群模式的策略,指定的时候是可以要求数据同步到所有节点的,也可以要求同步到指定数量的节点,再次创建 queue 的时候,应用这个策略,就会自动将数据同步到其他的节点上去了。

其他

(1)交换器无法根据自身类型和路由键找到符合条件队列时,有哪些处理方式:设置mandatory = true,代表返回消息给生产者;设置mandatory = false,代表直接丢弃

(2)消费者得到消息队列中的数据的方式:push 和 pull

(3)消息基于什么传输:

由于 TCP 连接的创建和销毁开销较大,且并发数受系统资源限制,会造成性能瓶颈。所以RabbitMQ 使用信道 channel 的方式来传输数据,信道是建立在真实的 TCP 连接内的虚拟连接,且每条 TCP 连接上的信道数量没有限制。

(4)死信队列DLX:

DLX也是一个正常的Exchange,和一般的Exchange没有任何区别。能在任何的队列上被指定,实际上就是设置某个队列的属性。当这个队列出现死信(dead message,就是没有任何消费者消费)的时候,RabbitMQ就会自动将这条消息重新发布到Exchange上去,进而被路由到另一个队列。可以监听这个队列中的消息作相应的处理。消息变为死信的几种情况:

  • 消息被拒绝(basic.reject/basic.nack)同时requeue=false(不重回队列)
  • TTL过期
  • 队列达到最大长度,无法再添加

(5)延迟队列:

存储对应的延迟消息,当消息被发送以后,并不想让消费者立刻拿到消息,而是等待特定时间后,消费者才能拿到这个消息进行消费。

(6)优先级队列:

优先级高的队列会先被消费,可以通过x-max-priority参数来实现。但是当消费速度大于生产速度且 Broker 没有堆积的情况下,优先级显得没有意义。

(7)RabbitMQ 要求集群中至少有一个磁盘节点,其他节点可以是内存节点,当节点加入或离开集群时,必须要将该变更通知到至少一个磁盘节点。如果只有一个磁盘节点,刚好又是该节点崩溃了,那么集群可以继续路由消息,但不能创建队列、创建交换器、创建绑定、添加用户、更改权限、添加或删除集群节点。也就是说集群中的唯一磁盘节点崩溃的话,集群仍然可以运行,但直到该节点恢复前,无法更改任何东西。

Redis

Redis为什么这么快

(1)完全基于内存,数据存在内存中,绝大部分请求是纯粹的内存操作,非常快速,跟传统的磁盘文件数据存储相比,避免了通过磁盘IO读取到内存这部分的开销。

(2)数据结构简单,对数据操作也简单。Redis中的数据结构是专门进行设计的,每种数据结构都有一种或多种数据结构来支持。Redis正是依赖这些灵活的数据结构,来提升读取和写入的性能。

(3)采用单线程,省去了很多上下文切换的时间以及CPU消耗,不存在竞争条件,不用去考虑各种锁的问题,不存在加锁释放锁操作,也不会出现死锁而导致的性能消耗。

(4)使用基于IO多路复用机制的线程模型,可以处理并发的链接。

Redis内部使用的文件事件处理器 file event handler,这个文件事件处理器是单线程的,所以Redis才叫做单线程的模型,他采用IO多路复用机制同时监听多个Socket,根据Socket上的事件来选择对应的事件处理器进行处理。文件事件处理器的结构包含4个部分:

  • 多个Socket
  • IO多路复用程序
  • 文件事件分派器
  • 事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)

多个Socket可能会产生不同的操作,每个操作对应不同的文件事件,但是IO多路复用程序会监听多个Socket,会将Socket产生的事件放入队列中排队,事件分派器每次从队列中取出一个事件,把该事件交给对应的事件处理器进行处理。

多路I/O复用模型是利用 select、poll、epoll 可以同时监察多个流的 I/O 事件的能力,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有 I/O 事件时,就从阻塞态中唤醒,然后程序就会轮询一遍所有的流(epoll 是只轮询那些真正发出了事件的流),并且依次顺序的处理就绪的流,这种做法就避免了大量的无用操作。

这里“多路”指的是多个网络连接,“复用”指的是复用同一个线程。采用多路 I/O 复用技术可以让单个线程高效的处理多个客户端的网络IO连接请求(尽量减少网络 IO 的时间消耗)

(5)Redis直接自己构建了VM 机制 ,避免调用系统函数的时候,浪费时间去移动和请求

为什么Redis是单线程

这里我们强调的单线程,指的是网络请求模块使用一个线程来处理,即一个线程处理所有网络请求,其他模块仍用了多个线程。

那为什么使用单线程呢?官方答案是:因为CPU不是Redis的瓶颈,Redis的瓶颈最有可能是机器内存或者网络带宽。既然单线程容易实现,而且CPU不会成为瓶颈,那就顺理成章地采用单线程的方案了。

但是,我们使用单线程的方式是无法发挥多核CPU 性能,不过我们可以通过在单机开多个Redis 实例来解决这个问题

5种数据结构

字符串(String)

​ 与其它编程语言或其它键值存储提供的字符串非常相似,键(key)------值(value) (字符串格式),字符串拥有一些操作命令,如:get set del 还有一些比如自增或自减操作等等。redis是使用C语言开发,但C中并没有字符串类型,只能使用指针或符数组的形式表示一个字符串,所以redis设计了一种简单动态字符串(SDS[Simple Dynamic String])作为底实现:

定义SDS对象,此对象中包含三个属性:

  • len buf中已经占有的长度(表示此字符串的实际长度)
  • free buf中未使用的缓冲区长度
  • buf[] 实际保存字符串数据的地方

所以取字符串的长度的时间复杂度为O(1),另,buf[]中依然采用了C语言的以\0结尾可以直接使用C语言的部分标准C字符串库函数。

空间分配原则:当len小于IMB(1024*1024)时增加字符串分配空间大小为原来的2倍,当len大于等于1M时每次分配 额外多分配1M的空间。

由此可以得出以下特性:

  • redis为字符分配空间的次数是小于等于字符串的长度N,而原C语言中的分配原则必为N。降低了分配次数提高了追加速度,代价就是多占用一些内存空间,且这些空间不会自动释放。
  • 二进制安全的
  • 高效的计算字符串长度(时间复杂度为O(1))
  • 高效的追加字符串操作。

列表(List)

​ redis对键表的结构支持使得它在键值存储的世界中独树一帜,一个列表结构可以有序地存储多个字符串,拥有例如:lpush lpop rpush rpop等等操作命令。在3.2版本之前,列表是使用ziplist和linkedlist实现的,在这些老版本中,当列表对象同时满足以下两个条件时,列表对象使用ziplist编码:

  • 列表对象保存的所有字符串元素的长度都小于64字节
  • 列表对象保存的元素数量小于512个

当有任一条件 不满足时将会进行一次转码,使用linkedlist。

而在3.2版本之后,重新引入了一个quicklist的数据结构,列表的底层都是由quicklist实现的,它结合了ziplist和linkedlist的优点。按照原文的解释这种数据结构是【A doubly linked list of ziplists】意思就是一个由ziplist组成的双向链表。那么这两种数据结构怎么样结合的呢?

ziplist的结构

​ 由表头和N个entry节点和压缩列表尾部标识符zlend组成的一个连续的内存块。然后通过一系列的编码规则,提高内存的利用率,主要用于存储整数和比较短的字符串。可以看出在插入和删除元素的时候,都需要对内存进行一次扩展或缩减,还要进行部分数据的移动操作,这样会造成更新效率低下的情况。

linkedlist的结构

​ 意思为一个双向链表,和普通的链表定义相同,每个entry包含向前向后的指针,当插入或删除元素的时候,只需要对此元素前后指针操作即可。所以插入和删除效率很高。但查询的效率却是O(n)[n为元素的个数]。

了解了上面的这两种数据结构,我们再来看看上面说的“ziplist组成的双向链表”是什么意思?实际上,它整体宏观上就是一个链表结构,只不过每个节点都是以压缩列表ziplist的结构保存着数据,而每个ziplist又可以包含多个entry。也可以说一个quicklist节点保存的是一片数据,而不是一个数据。总结:

  • 整体上quicklist就是一个双向链表结构,和普通的链表操作一样,插入删除效率很高,但查询的效率却是O(n)。不过,这样的链表访问两端的元素的时间复杂度却是O(1)。所以,对list的操作多数都是poll和push。
  • 每个quicklist节点就是一个ziplist,具备压缩列表的特性。

在redis.conf配置文件中,有两个参数可以优化列表:

  1. list-max-ziplist-size 表示每个quicklistNode的字节大小。默认为-2 表示8KB
  2. list-compress-depth 表示quicklistNode节点是否要压缩。默认是0 表示不压缩

哈希(hash)

​ redis的散列可以存储多个键 值 对之间的映射,散列存储的值既可以是字符串又可以是数字值,并且用户同样可以对散列存储的数字值执行自增操作或者自减操作。散列可以看作是一个文档或关系数据库里的一行。hash底层的数据结构实现有两种:

  • 一种是ziplist,上面已经提到过。当存储的数据超过配置的阀值时就是转用hashtable的结构。这种转换比较消耗性能,所以应该尽量避免这种转换操作。同时满足以下两个条件时才会使用这种结构:
    • 当键的个数小于hash-max-ziplist-entries(默认512)
    • 当所有值都小于hash-max-ziplist-value(默认64)
  • 另一种就是hashtable。这种结构的时间复杂度为O(1),但是会消耗比较多的内存空间。

集合(Set)

​ redis的集合和列表都可以存储多个字符串,它们之间的不同在于,列表可以存储多个相同的字符串,而集合则通过使用散列表(hashtable)来保证自已存储的每个字符串都是各不相同的(这些散列表只有键,但没有与键相关联的值),redis中的集合是无序的。还可能存在另一种集合,那就是intset,它是用于存储整数的有序集合,里面存放同一类型的整数。共有三种整数:int16_t、int32_t、int64_t。查找的时间复杂度为O(logN),但是插入的时候,有可能会涉及到升级(比如:原来是int16_t的集合,当插入int32_t的整数的时候就会为每个元素升级为int32_t)这时候会对内存重新分配,所以此时的时间复杂度就是O(N)级别的了。注意:intset只支持升级不支持降级操作。

intset在redis.conf中也有一个配置参数set-max-intset-entries默认值为512。表示如果entry的个数小于此值,则可以编码成REDIS_ENCODING_INTSET类型存储,节约内存。否则采用dict的形式存储。

有序集合(zset)

​ 有序集合和散列一样,都用于存储键值对:有序集合的键被称为成员(member),每个成员都是各不相同的。有序集合的值则被称为分值(score),分值必须为浮点数。有序集合是redis里面唯一一个既可以根据成员访问元素(这一点和散列一样),又可以根据分值以及分值的排列顺序访问元素的结构。它的存储方式也有两种:

  • 是ziplist结构。

​ 与上面的hash中的ziplist类似,member和score顺序存放并按score的顺序排列

  • 另一种是skiplist与dict的结合。

​ skiplist是一种跳跃表结构,用于有序集合中快速查找,大多数情况下它的效率与平衡树差不多,但比平衡树实现简单。redis的作者对普通的跳跃表进行了修改,包括添加span\tail\backward指针、score的值可重复这些设计,从而实现排序功能和反向遍历的功能。

一般跳跃表的实现,主要包含以下几个部分:

  • 表头(head):指向头节点
  • 表尾(tail):指向尾节点
  • 节点(node):实际保存的元素节点,每个节点可以有多层,层数是在创建此节点的时候随机生成的一个数值,而且每一层都是一个指向后面某个节点的指针。
  • 层(level):目前表内节点的最大层数
  • 长度(length):节点的数量。

跳跃表的遍历总是从高层开始,然后随着元素值范围的缩小,慢慢降低到低层。

前面也说了,有序列表是使用skiplist和dict结合实现的,skiplist用来保障有序性和访问查找性能,dict就用来存储元素信息,并且dict的访问时间复杂度为O(1)。

五种类型的应用场景

  1. String,redis对于KV的操作效率很高,可以直接用作计数器。例如,统计在线人数等等,另外string类型是二进制存储安全的,所以也可以使用它来存储图片,甚至是视频等。
  2. hash,存放键值对,一般可以用来存某个对象的基本属性信息,例如,用户信息,商品信息等,另外,由于hash的大小在小于配置的大小的时候使用的是ziplist结构,比较节约内存,所以针对大量的数据存储可以考虑使用hash来分段存储来达到压缩数据量,节约内存的目的,例如,对于大批量的商品对应的图片地址名称。比如:商品编码固定是10位,可以选取前7位做为hash的key,后三位作为field,图片地址作为value。这样每个hash表都不超过999个,只要把redis.conf中的hash-max-ziplist-entries改为1024,即可。
  3. list,列表类型,可以用于实现消息队列,也可以使用它提供的range命令,做分页查询功能。
  4. set,集合,整数的有序列表可以直接使用set。可以用作某些去重功能,例如用户名不能重复等,另外,还可以对集合进行交集,并集操作,来查找某些元素的共同点
  5. zset,有序集合,可以使用范围查找,排行榜功能或者topN功能。

Redis的持久化机制

Redis的持久化机制有两种,第一种是RDB快照,第二种是AOF日志。RDB快照是一次全量备份,AOF是连续的增量备份。快照是内存数据的二进制序列化形式,在存储上非常紧凑,而 AOF 日志记录的是内存数据修改的指令记录文本。

一、RDB机制:

RDB快照就是把数据以快照的形式保存在磁盘上,是某个时间点的一次全量数据备份,以二进制序列化形式的文件存储,并且在存储上非常紧密。

RDB持久化是指在指定的时间间隔内将内存中的数据集以快照的方式写入磁盘,并保存到一个名为dump.rdb的二进制文件中,也是默认的持久化方式,它恢复时是将快照文件从磁盘直接读到内存里。

1、触发机制:

RDB来说持久化触发机制有三种:save、bgsave、自动化触发

(1)save命令触发:

该命令会阻塞当前Redis服务器,执行save命令期间,Redis不能处理其他命令,直到RDB完成为止,如果数据量大的话会造成长时间的阻塞,所以线上环境一般禁止使用。

(2)bgsave命令触发:

执行该命令时,Redis会在后台异步进行快照操作,快照同时还可以响应客户端请求。具体流程如下:

执行bgsave命令时,Redis主进程会fork一个子进程来完成RDB的过程,会先将数据写入到一个临时二进制文件中,待持久化过程都结束了,再用这个临时文件替换上次持久化好的文件(可以理解为Copy On Write机制)。Redis主进程阻塞时间只有fork阶段的那一下。相对于save,阻塞时间很短。基本上 Redis 内部所有的RDB操作都是采用 bgsave 命令。

fork的作用是复制一个与当前进程一样的进程。新进程的所有数据(变量、环境变量、程序计数器等)数值都和原进程一致,但是是一个全新的进程,并作为原进程的子进程。

(3)自动触发:

自动触发是可以在redis.conf配置文件中修改,默认达到以下三种条件,就会自动触发持久化,触发后,底层调用的其实还有bgsave命令:

1分钟内改了1万次,5分钟内改了10次,或15分钟内改了1次。

save 900 1 #在900秒(15分钟)之后,如果至少有1个key发生变化,则dump内存快照。

save 300 10 #在300秒(5分钟)之后,如果至少有10个key发生变化,则dump内存快照。

save 60 10000 #在60秒(1分钟)之后,如果至少有10000个key发生变化,则dump内存快照。

如果不需要持久化机制,则可以注释掉所有的save命令

2、RDB执行流程:

(1)执行bgsave命令的时候,Redis主进程会检查是否有子进程在执行RDB/AOF持久化任务,如果有的话,直接返回,主要是为了性能的考虑,防止两个进程同时对磁盘进行写入操作

(2)Redis主进程fork一个子进程来执行执行RDB操作,fork操作会对主进程造成阻塞(影响Redis的读写),fork操作完成后会发消息给主进程,从而不再阻塞主进程(阻塞仅指主进程fork子进程的过程,后续子进程执行操作时不会阻塞)

(3)RDB子进程把Redis主进程的内存数据,写入到一个临时的快照文件,持久化完成后,再使用临时快照文件替换掉原来的RDB文件。(该过程中主进程的读写不受影响,但Redis的写操作不会同步到主进程的主内存中,而是会写到一个临时的内存区域作为一个副本)

(4)子进程完成RDB持久化后会发消息给主进程,通知RDB持久化完成,并将步骤(3)中的内存副本中的增量写数据同步到主内存

3、优势:

(1)RDB文件紧凑,全量备份,非常适合用于进行备份和灾难恢复。

(2)对于大规模数据的恢复,且对于数据恢复的完整性不是非常敏感的场景,RDB的恢复速度要比AOF方式更加的高效。

(3)生成RDB文件的时候,redis主进程会fork()一个子进程来处理所有保存工作,主进程不需要进行任何磁盘IO操作。

4、劣势:

(1)fork的时候,内存中的数据被克隆了一份,大致2倍的膨胀性需要考虑。

(2)当进行快照持久化时,会开启一个子进程专门负责快照持久化,子进程会拥有父进程的内存数据,父进程修改内存子进程不会反应出来,所以在快照持久化期间修改的数据不会被保存,可能丢失数据。

(3)在一定间隔时间做一次备份,所以如果redis意外down掉的话,就会丢失最后一次快照后的所有修改。

二、AOF机制:

1、什么是AOF:

每次都使用RDB机制全量备份的方式是非常耗时间的,因此Redis还提供了另一种持久化机制AOF(append only file)。AOF日志是持续增量的备份,将Redis执行过的每个写操作以日志的形式记录下来(读操作不记录),只许追加文件但不可以改写文件(appendonly.aof文件)。redis启动的时候会读取该文件进行数据恢复,根据日志文件的内容将写指令从前到后执行一次以完成数据的恢复工作。

2、AOF文件的rewrite机制:

(1)因为AOF采用文件追加方式,文件会越来越大,为避免出现此种情况,需要定期进行AOF重写,当AOF文件的大小超过所设定的阈值时,Redis就会对AOF文件的内容压缩,只保留可以恢复数据的最小指令集。redis提供了bgrewriteaof命令,fork一个新的进程对aof文件进行重写。

(2)重写原理:AOF文件持续增长而过大时,会fork出一条新进程来重写aof文件,将内存中的整个数据库内容用命令的方式重写了一个新的aof文件(注意:在重写时并不是读取旧的aof文件),在执行 BGREWRITEAOF 命令时,Redis 服务器会维护一个 AOF 重写缓冲区,该缓冲区会在子进程创建新AOF文件期间,记录服务器执行的所有写命令。当子进程完成创建新AOF文件的工作之后,服务器会将重写缓冲区中的所有内容追加到新AOF文件的末尾,使得新旧两个AOF文件所保存的数据库状态一致。最后,服务器用新的AOF文件替换旧的AOF文件,以此来完成AOF文件重写操作。

(3)触发时机:Redis会记录上次重写时的AOF大小,默认配置是当AOF文件大小是上次rewrite后大小的一倍且文件大于64M时触发。

3、AOF持久化触发机制:

(1)每修改同步:appendfsync always 同步持久化,每次发生数据变更会被立即记录到磁盘,性能较差但数据完整性比较好。

(2)每秒同步:appendfsync everysec 异步操作,每秒记录,如果一秒内宕机,有数据丢失。

(3)不同步:appendfsync no 从不同步

Linux 的 glibc 提供了 fsync()函数可以将指定文件的内容强制从内核缓存刷到磁盘。只要 Redis 进程实时调用 fsync 函数就可以保证 aof 日志不丢失。但是 fsync 是一个磁盘 IO 操作,它速度很慢,如果每条指令都要 fsync 一次,那么 Redis 高性能的地位就不保了。

4、优点:

(1)AOF可以更好的保护数据不丢失,一般AOF会每隔1秒,通过一个后台线程执行一次fsync操作,最多丢失1秒钟的数据。

(2)AOF只是追加写日志文件,对服务器性能影响较小,速度比RDB要快,消耗的内存较少

(3)AOF日志文件即使过大的时候,出现后台重写操作,也不会影响客户端的读写。

(4)AOF日志文件的命令通过非常可读的方式进行记录,这个特性非常适合做灾难性的误删除的紧急恢复。比如某人不小心用flushall命令清空了所有数据,只要这个时候后台rewrite还没有发生,那么就可以立即拷贝AOF文件,将最后一条flushall命令给删了,然后再将该AOF文件放回去,就可以通过恢复机制,自动恢复所有数据。

5、劣势:

(1)对于相同数据集的数据而言,aof文件要远大于rdb文件,恢复速度慢于rdb。

(2)对于每秒一次同步的情况,aof运行效率要慢于rdb,不同步效率和rdb相同。

注:如果同时开启两种持久化方式,在这种情况下,当redis重启的时候会优先载入AOF文件来恢复原始的数据,因为在通常情况下AOF文件保存的数据集要比RDB文件保存的数据集要完整。

Redis4.0的混合持久化

  • 仅使用RDB快照方式恢复数据,由于快照时间粒度较大时,会丢失大量数据。
  • 仅使用AOF重放方式恢复数据,日志性能相对 rdb 来说要慢。在 Redis 实例很大的情况下,启动需要花费很长的时间。

为了解决这个问题,Redis4.0开始支持RDB和AOF的混合持久化(默认关闭,可以通过配置项 aof-use-rdb-preamble 开启)。RDB 文件的内容和增量的 AOF 日志文件存在一起,这里的 AOF 日志不再是全量的日志,而是自持久化开始到持久化结束的这段时间发生的增量 AOF 日志,通常这部分 AOF 日志很小

  • 大量数据使用粗粒度(时间上)的rdb快照方式,性能高,恢复时间快。
  • 增量数据使用细粒度(时间上)的AOF日志方式,尽量保证数据的不丢失。

在Redis重启时,进行AOF重写的时候就直接把RDB的内容写到 AOF 文件开头。这样做的好处是可以结合 RDB和 AOF 的优点,快速加载同时避免丢失过多的数据。当然缺点也是有的, AOF 里面的 RDB 部分是压缩格式不再是AOF 格式,可读性较差。

另外,可以使用下面这种方式:Master使用AOF,Slave使用RDB快照,master需要首先确保数据完整性,它作为数据备份的第一选择;slave提供只读服务或仅作为备机,它的主要目的就是快速响应客户端read请求或灾切换。

过期清除策略

如果我们设置了Redis的key-value的过期时间,当缓存中的数据过期之后,Redis就需要将这些数据进行清除,释放占用的内存空间。Redis中主要使用 定期删除 + 惰性删除 两种数据过期清除策略。

1、过期策略:定期删除+惰性删除:

(1)定期删除:redis默认每隔100ms就随机抽取一些设置了过期时间的key,检查其是否过期,如果有过期就删除。注意这里是随机抽取的。为什么要随机呢?你想一想假如 redis 存了几十万个 key ,每隔100ms就遍历所有的设置过期时间的 key 的话,就会给 CPU 带来很大的负载。

为什么不用定时删除策略呢?

定时删除,用一个定时器来负责监视key,过期则自动删除。虽然内存及时释放,但是十分消耗CPU资源。在大并发请求下,CPU要将时间应用在处理请求,而不是删除key,因此没有采用这一策略。

(2)惰性删除:定期删除可能导致很多过期的key 到了时间并没有被删除掉。这时就要使用到惰性删除。在你获取某个key的时候,redis会检查一下,这个key如果设置了过期时间并且过期了,是的话就删除。

2、定期删除+惰性删除存在的问题:

如果某个key过期后,定期删除没删除成功,然后也没再次去请求key,也就是说惰性删除也没生效。这时,如果大量过期的key堆积在内存中,redis的内存会越来越高,导致redis的内存块耗尽。那么就应该采用内存淘汰机制。

缓存淘汰策略

1、noeviction:不进行淘汰数据。一旦缓存被写满,再有写请求进来,Redis就不再提供服务,而是直接返回错误。Redis 用作缓存时,实际的数据集通常都是大于缓存容量的,总会有新的数据要写入缓存,这个策略本身不淘汰数据,也就不会腾出新的缓存空间,我们不把它用在 Redis 缓存中。

2、volatile-ttl:在设置了过期时间的键值对中,移除即将过期的键值对。

3、volatile-random:在设置了过期时间的键值对中,随机移除某个键值对。

4、volatile-lru:在设置了过期时间的键值对中,移除最近最少使用的键值对。

5、volatile-lfu:在设置了过期时间的键值对中,移除最近最不频繁使用的键值对

6、allkeys-random:在所有键值对中,随机移除某个key。

7、allkeys-lru:在所有的键值对中,移除最近最少使用的键值对。

8、allkeys-lfu:在所有的键值对中,移除最近最不频繁使用的键值对

通常情况下推荐优先使用 allkeys-lru 策略。这样可以充分利用 LRU 这一经典缓存算法的优势,把最近最常访问的数据留在缓存中,提升应用的访问性能。

如果你的业务数据中有明显的冷热数据区分,建议使用 allkeys-lru 策略。

如果业务应用中的数据访问频率相差不大,没有明显的冷热数据区分,建议使用 allkeys-random 策略,随机选择淘汰的数据就行。

如果没有设置过期时间的键值对,那么 volatile-lru,volatile-lfu,volatile-random 和 volatile-ttl 策略的行为, 和 noeviction 基本上一致。

Redis中的LRU和LFU算法:

LRU算法:

Redis 默认会记录每个数据的最近一次访问的时间戳(由键值对数据结构 RedisObject 中的 lru 字段记录)。然后,Redis 在决定淘汰的数据时,第一次会随机选出 N 个数据,把它们作为一个候选集合。接下来,Redis 会比较这 N 个数据的 lru 字段,把 lru 字段值最小的数据从缓存中淘汰出去。当需要再次淘汰数据时,Redis 需要挑选数据进入第一次淘汰时创建的候选集合。这里的挑选标准是:能进入候选集合的数据的 lru 字段值必须小于候选集合中最小的 lru 值。当有新数据进入候选数据集后,如果候选数据集中的数据个数达到了 N 个,Redis 就把候选数据集中 lru 字段值最小的数据淘汰出去。

LFU算法:

LFU是在Redis4.0后出现的,它的核心思想是根据key的最近被访问的频率进行淘汰,很少被访问的优先被淘汰,被访问的多的则被留下来。LFU算法能更好的表示一个key被访问的热度。假如你使用的是LRU算法,一个key很久没有被访问到,只刚刚是偶尔被访问了一次,那么它就被认为是热点数据,不会被淘汰,而有些key将来是很有可能被访问到的则被淘汰了。如果使用LFU算法则不会出现这种情况,因为使用一次并不会使一个key成为热点数据。它的使用与LRU有所区别:

LFU (Least Frequently Used) :最近最不频繁使用,跟使用的次数有关,淘汰使用次数最少的。

LRU (Least Recently Used):最近最少使用,跟使用的最后一次时间有关,淘汰最近使用时间离现在最久的。

主从复制

1、主从复制的架构:

Redis Replication是一种 master-slave 模式的复制机制,这种机制使得 slave 节点可以成为与 master 节点完全相同的副本,可以采用一主多从或者级联结构。架构如下:

img

主从复制的配置要点:

(1)配从库不配主,从库配置:slaveof 主库IP 主库端口

(2)查看redis的配置信息:info replication

2、Redis为什么需要主从复制?

使用Redis主从复制的原因主要是单台Redis节点存在以下的局限性:

(1)Redis虽然读写的速度都很快,单节点的Redis能够支撑QPS大概在5w左右,如果上千万的用户访问,Redis就承载不了,成为了高并发的瓶颈。

(2)单节点的Redis不能保证高可用,当Redis因为某些原因意外宕机时,会导致缓存不可用

(3)CPU的利用率上,单台Redis实例只能利用单个核心,这单个核心在面临海量数据的存取和管理工作时压力会非常大。

主从复制的优缺点

优点:

(1)数据冗余:主从复制实现了数据的热备份,是持久化之外的一种数据冗余方式。

(2)故障恢复:如果master宕掉了,使用哨兵模式,可以提升一个 slave 作为新的 master,进而实现故障转移,实现高可用

(3)负载均衡:可以轻易地实现横向扩展,实现读写分离,一个 master 用于写,多个 slave 用于分摊读的压力,从而实现高并发;

缺点:

由于所有的写操作都是先在Master上操作,然后同步更新到Slave上,所以从Master同步到Slave服务器有一定的延迟,当系统很繁忙的时候,延迟问题会更加严重,Slave机器数量的增加也会使这个问题更加严重

主从复制的原理

从总体上来说,Redis主从复制的策略就是:当主从服务器刚建立连接的时候,进行全量同步;全量复制结束后,进行增量复制。当然,如果有需要,slave 在任何时候都可以发起全量同步。

主从全量复制的流程

Redis全量复制一般发生在Slave初始化阶段,这时Slave需要将Master上的所有数据都复制一份,具体步骤如下:

(1)slave服务器连接到master服务器,便开始进行数据同步,发送psync命令(Redis2.8之前是sync命令)

(2)master服务器收到psync命令之后,开始执行bgsave命令生成RDB快照文件并使用缓存区记录此后执行的所有写命令

  • 如果master收到了多个slave并发连接请求,它只会进行一次持久化,而不是每个连接都执行一次,然后再把这一份持久化的数据发送给多个并发连接的slave。
  • 如果RDB复制时间超过60秒(repl-timeout),那么slave服务器就会认为复制失败,可以适当调节大这个参数

(3)master服务器bgsave执行完之后,就会向所有Slava服务器发送快照文件,并在发送期间继续在缓冲区内记录被执行的写命令

client-output-buffer-limit slave 256MB 64MB 60,如果在复制期间,内存缓冲区持续消耗超过64MB,或者一次性超过256MB,那么停止复制,复制失败

(4)slave服务器收到RDB快照文件后,会将接收到的数据写入磁盘,然后清空所有旧数据,在从本地磁盘载入收到的快照到内存中,同时基于旧的数据版本对外提供服务。

(5)master服务器发送完RDB快照文件之后,便开始向slave服务器发送缓冲区中的写命令

(6)slave服务器完成对快照的载入,开始接收命令请求,并执行来自主服务器缓冲区的写命令;

(7)如果slave node开启了AOF,那么会立即执行BGREWRITEAOF,重写AOF

2、增量复制:

Redis的增量复制是指在初始化的全量复制并开始正常工作之后,master服务器将发生的写操作同步到slave服务器的过程,增量复制的过程主要是master服务器每执行一个写命令就会向slave服务器发送相同的写命令,slave服务器接收并执行收到的写命令。

3、断点续传:

3.1、什么是断点续传:

当master-slave网络连接断掉后,slave重新连接master时,会触发全量复制,但是从2.8版本开始,slave与master能够在网络连接断开重连后,只从中断处继续进行复制,而不必重新同步,这就是所谓的断点续传。

断电续传这个新特性使用psync命令,旧的实现中使用sync命令。Redis2.8版本可以检测出它所连接的服务器是否支持PSYNC命令,不支持的话使用SYNC命令。master服务器收到slave发送的psync命令后,会根据自身的情况做出对应的处理,可能是FULLRESYNC runid offset触发全量复制,也可能是CONTINUE触发增量复制

命令格式:psync runid offset

3.2、工作原理:

(1)master服务器在内存缓冲区中给每个slave服务器都维护了一份同步备份日志(in-memory backlog),缓存最近一段时间的数据,默认大小1m,如果超过这个大小就会清理掉。

(2)同时,master 和 slave 服务器都维护了一个复制偏移量(replication offset)和 master线程ID(master run id),每个slave服务器在跟master服务器进行同步时都会携带master run id 和 最后一次同步的复制偏移量offset,通过offset可以知道主从之间的数据不一致的情况。

(3)当连接断开时,slave服务器会重新连接上master服务器,然后请求继续复制。假如主从服务器的两个master run id相同,并且指定的偏移量offset在同步备份日志中还有效,复制就会从上次中断的点开始继续。如果其中一个条件不满足,就会进行完全重新同步,因为主运行id不保存在磁盘中,如果从服务器重启的话就只能进行完全同步了。

master服务器维护的offset是存储在backlog中,msater就是根据slave发送的offset来从backlog中获取数据的

(4)在部分同步过程中,master会将本地记录的同步备份日志中记录的指令依次发送给slave服务器从而达到数据一致。

4、无磁盘化复制:

在前面全量复制的过程中,master会将数据保存在磁盘的rdb文件中然后发送给slave服务器,但如果master上的磁盘空间有限或者是使用比较低速的磁盘,这种操作会给master服务器带来较大的压力,那怎么办呢?在Redis2.8之后,可以通过无盘复制来达到目的,由master直接开启一个socket,在内存中创建RDB文件,再将rdb文件发送给slave服务器,不使用磁盘作为中间存储。(无盘复制一般应用在磁盘空间有限但是网络状态良好的情况下)

img

repl-diskless-sync :是否开启无磁盘复制
repl-diskless-sync-delay:等待一定时长再开始复制,因为要等更多slave重新连接过来

主从复制的特点

(1)Redis使用异步复制,每次接收到写命令之后,先在内部写入数据,然后异步发送给slave服务器。但从Redis 2.8开始,从服务器会周期性的应答从复制流中处理的数据量。

(2)Redis主从复制不阻塞master服务器。也就是说当若干个从服务器在进行初始同步时,主服务器仍然可以处理外界请求。

(3)主从复制不阻塞slave服务器。当master服务器进行初始同步时,slave服务器返回的是以前旧版本的数据,如果你不想这样,那么在启动redis配置文件中进行设置,那么从redis在同步过程中来自外界的查询请求都会返回错误给客户端;

虽然说主从复制过程中对于从redis是非阻塞的,它会用旧的数据集来提供服务,但是当初始同步完成后,需删除旧数据集和加载新的数据集,在这个短暂时间内,从服务器会阻塞连接进来的请求,对于大数据集,加载到内存的时间也是比较多的

(4)主从复制提高了redis服务的扩展性,避免单个redis服务器的读写访问压力过大的问题,同时也可以给为数据备份及冗余提供一种解决方案;

(5)使用主从复制可以为master服务器免除把数据写入磁盘的消耗,可以配置让master服务器不再将数据持久化到磁盘,而是通过连接让一个配置的slave类型的Redis服务器及时将相关数据持久化到磁盘。不过这种做法存在master类型的Redis服务器一旦重启,因为此时master服务器不进行持久化,所以数据为空,这时候通过主从同步可能导致slave类型的Redis服务器上的数据也被清空,所以这个配置要确保主服务器不会自动重启(详见第2点的“master开启持久化对主从架构的安全意义”)

2、master开启持久化对主从架构的安全意义:

使用主从架构,必须开启master服务器的持久化机制,不建议用slave服务器作为master服务器的数据热备。当不能这么做时,比如考虑到延迟的问题,应该将master服务器配置为避免自动重启。否则,在关闭master服务器持久化机制并开始自动重启的情况下,会造成主从服务器数据被清空的情况。也就是master的持久化关闭,可能在master宕机重启的时候数据是空的(RDB和AOF都关闭了),此时就会将空数据复制到slave ,导致slave服务器的数据也丢了。

为了更好的理解这个问题,看下面这个失败的例子,其中主服务器和从服务器中数据库都被删除了:

设置节点A为主服务器,关闭持久化,节点B和C从节点A复制数据。这时出现了一个崩溃,但Redis具有自动重启系统,重启了进程,因为关闭了持久化,节点重启后只有一个空的数据集。节点B和C从节点A进行复制,现在节点A是空的,所以节点B和C上的复制数据也会被删除。当在高可用系统中使用Redis Sentinel,关闭了主服务器的持久化,并且允许自动重启,这种情况是很危险的。比如主服务器可能在很短的时间就完成了重启,以至于Sentinel都无法检测到这次失败,那么上面说的这种失败的情况就发生了。

3、过期key的处理:

对于slave服务器上过期键的处理,主要是有master服务器负责。如果master过期了一个key,则由master服务器负责键的过期删除处理,然后将相关删除命令以数据同步的方式同步给slave服务器,slave服务器根据删除命令删除本地的key。

Redis的高可用:

前面说过,通过主从复制,如果master服务器宕机了,可以提升一个slave服务器作为新的master服务器,实现故障的转移,实现高可用。也就是说,当master宕掉之后,可以手动执行“SLAVEOF no one”命令,重新选择一台服务器作为master服务器。但是呢,我们总不能保证每次master宕掉之后,都可以及时察觉并手动执行该命令,这时就可以使用“哨兵模式sentinel”,哨兵模式其实就是“SLAVEOF no one”命令的自动版,能够后台监控master是否故障,如果故障了,则根据投票数自动将slave转换为master,如果之前的master重启回来,不会造成双master冲突,因为原本的master会变成slave。

配置步骤:

(1)在自定义的/myredis目录下新建sentinel.conf文件(名字绝不能错)。

(2)配置哨兵,在配置文件中写: sentinel monitor 被监控数据库名字 127.0.0.1 6379 1

最后的数字1,表示主机挂掉后salve投票看让谁接替成为主机,得票数多少后成为主机。

(3)启动哨兵:redis-sentinel /myredis/sentinel.conf

哨兵机制

概念

在主从模式下,master节点负责写请求,然后异步同步给slave节点,从节点负责处理读请求。如果master宕机了,需要手动将从节点晋升为主节点,并且还要切换客户端的连接数据源。这就无法达到高可用,而通过哨兵模式就可以解决这一问题。

哨兵模式是Redis的高可用方式,哨兵节点是特殊的redis服务,不提供读写服务,主要用来监控redis实例节点。 哨兵架构下client端第一次从哨兵找出redis的主节点,后续就直接访问redis的主节点,不会每次都通过sentinel代理访问redis的主节点,当redis的主节点挂掉时,哨兵会第一时间感知到,并且在slave节点中重新选出来一个新的master,然后将新的master信息通知给client端,从而实现高可用。这里面redis的client端一般都实现了订阅功能,订阅sentinel发布的节点变动消息。

哨兵的主要工作任务:

(1)监控:哨兵会不断地检查你的Master和Slave是否运作正常。

(2)提醒:当被监控的某个Redis节点出现问题时,哨兵可以通过 API 向管理员或者其他应用程序发送通知。

(3)自动故障迁移:当一个Master不能正常工作时,哨兵会进行自动故障迁移操作,将失效Master的其中一个Slave升级为新的Master,并让失效Master的其他Slave改为复制新的Master;当客户端试图连接失效的Master时,集群也会向客户端返回新Master的地址,使得集群可以使用新Master代替失效Master。

工作原理

哨兵是一个分布式系统,可以在一个架构中运行多个哨兵进程,这些进程使用流言协议(gossip protocols)来传播Master是否下线的信息,并使用投票协议(agreement protocols)来决定是否执行自动故障迁移,以及选择哪个Slave作为新的Master。哨兵模式的具体工作原理如下:

1、心跳机制:

(1)Sentinel 与 Redis Node:Redis Sentinel 是一个特殊的 Redis 节点。在哨兵模式创建时,需要通过配置指定 Sentinel 与 Redis Master Node 之间的关系,然后 Sentinel 会从主节点上获取所有从节点的信息,之后 Sentinel 会定时向主节点和从节点发送 info 命令获取其拓扑结构和状态信息。

(2)Sentinel与Sentinel:基于 Redis 的订阅发布功能, 每个 Sentinel 节点会向主节点的 sentinel:hello 频道上发送该 Sentinel 节点对于主节点的判断以及当前 Sentinel 节点的信息 ,同时每个 Sentinel 节点也会订阅该频道, 来获取其他 Sentinel 节点的信息以及它们对主节点的判断

通过以上两步所有的 Sentinel 节点以及它们与所有的 Redis 节点之间都已经彼此感知到,之后每个 Sentinel 节点会向主节点、从节点、以及其余 Sentinel 节点定时发送 ping 命令作为心跳检测, 来确认这些节点是否可达。

2、判断master节点是否下线:

(1)每个 sentinel 哨兵节点每隔1s 向所有的master、slave以及其他 sentinel 节点发送一个PING命令,作用是通过心跳检测,检测主从服务器的网络连接状态

(2)如果 master 节点回复 PING 命令的时间超过 down-after-milliseconds 设定的阈值(默认30s),则这个 master 会被 sentinel 标记为主观下线,修改其 flags 状态为SRI_S_DOWN

(3)当sentinel 哨兵节点将 master 标记为主观下线后,会向其余所有的 sentinel 发送sentinel is-master-down-by-addr消息,询问其他sentinel是否同意该master下线

发送命令:sentinel is-master-down-by-addr <ip> <port> <current_epoch> </current_epoch></port></ip>

ip:主观下线的服务ip

port:主观下线的服务端口

current_epoch:sentinel的纪元

runid:*表示检测服务下线状态,如果是sentinel的运行id,表示用来选举领头sentinel

(4)每个sentinel收到命令之后,会根据发送过来的 ip和port 检查自己判断的结果,回复自己是否认为该master节点已经下线了

回复内容主要包含三个参数(由于上面发送的runid参数是*,这里先忽略后两个参数)

down_state(1表示已下线,0表示未下线)

leader_runid(领头sentinal id)

leader_epoch(领头sentinel纪元)。

(5)sentinel收到回复之后,如果同意master节点进入主观下线的sentinel数量大于等于quorum,则master会被标记为客观下线,即认为该节点已经不可用。

(6)在一般情况下,每个 Sentinel 每隔 10s 向所有的Master,Slave发送 INFO 命令。当Master 被 Sentinel 标记为客观下线时,Sentinel 向下线的 Master 的所有 Slave 发送 INFO 命令的频率会从 10 秒一次改为每秒一次。作用:发现最新的集群拓扑结构

3、基于Raft算法选举领头sentinel:

到现在为止,已经知道了master客观下线,那就需要一个sentinel来负责故障转移,那到底是哪个sentinel节点来做这件事呢?需要通过选举实现,具体的选举过程如下:

(1)判断客观下线的sentinel节点向其他 sentinel 节点发送 SENTINEL is-master-down-by-addr ip port current_epoch runid

注意:这时的runid是自己的run id,每个sentinel节点都有一个自己运行时id

(2)目标sentinel回复是否同意master下线并选举领头sentinel,选择领头sentinel的过程符合先到先得的原则。举例:sentinel1判断了客观下线,向sentinel2发送了第一步中的命令,sentinel2回复了sentinel1,说选你为领头,这时候sentinel3也向sentinel2发送第一步的命令,sentinel2会直接拒绝回复

(3)当sentinel发现选自己的节点个数超过 majority 的个数的时候,自己就是领头节点

(4)如果没有一个sentinel达到了majority的数量,等一段时间,重新选举

4、故障转移:

有了领头sentinel之后,下面就是要做故障转移了,故障转移的一个主要问题和选择领头sentinel问题差不多,到底要选择哪一个slaver节点来作为master呢?按照我们一般的常识,我们会认为哪个slave节点中的数据和master中的数据相识度高哪个slaver就是master了,其实哨兵模式也差不多是这样判断的,不过还有别的判断条件,详细介绍如下:

(1)在进行选择之前需要先剔除掉一些不满足条件的slaver,这些slaver不会作为变成master的备选

  • 剔除列表中已经下线的从服务
  • 剔除有5s没有回复sentinel的info命令的slave
  • 剔除与已经下线的主服务连接断开时间超过 down-after-milliseconds * 10 + master宕机时长 的slaver

(2)选主过程:

① 选择优先级最高的节点,通过sentinel配置文件中的replica-priority配置项,这个参数越小,表示优先级越高

② 如果第一步中的优先级相同,选择offset最大的,offset表示主节点向从节点同步数据的偏移量,越大表示同步的数据越多

③ 如果第二步offset也相同,选择run id较小的

5、修改配置:

新的master节点选择出来之后,还需要做一些事情配置的修改,如下:

(1)领头sentinel会对选出来的从节点执行slaveof no one 命令让其成为主节点

(2)领头sentinel 向别的slave发送slaveof命令,告诉他们新的master是谁谁谁,你们向这个master复制数据

(3)如果之前的master重新上线时,领头sentinel同样会给起发送slaveof命令,将其变成从节点

事务

Redis事务的相关命令:

1、MULTI:

用于标记事务块的开启。MULTI执行之后,Redis会将后续的命令逐个放到一个缓存队列中,当EXEC命令被调用时,所有队列中的命令才会被原子化执行。

2、EXEC:

在一个事务中执行所有先前放入队列的命令,然后恢复正常的连接状态。当使用WATCH命令时,只有当受监控的键没有被修改时,EXEC命令才会执行事务中的命令。

3、DISCARD:

放弃事务,清除事务队列中的命令,然后恢复正常的连接状态。如果使用了UNWATCH命令,那么DISCARD命令就会取消当前连接监控的所有键。

4、WATCH:

当某个事务需要按条件执行时,就要使用该命令将key设置为受监控的。如果在事务执行之前这些key被其他命令所改动,那么整个事务将会被打断。WATCH命令可用于提供CAS功能。

5、UNWATCH:

清除事务中所有监控的键。如果调用了EXEC或DISCARD命令,那么就不需要手动调用UNWATCH命令。

Redis事务原理:

1、事务的定义:

Redis的事务本质是一组命令的集合,一个事务中的命令要么全部执行,要么都不执行。事务的原理是先将属于一个事务的命令发送给Redis,存放到一个队列中,再让Redis依次执行这些命令。如果在发送EXEC命令前客户端断线了,则Redis会清空事务队列,事务中的所有命令都不会执行。而一旦客户端发送了EXEC命令,所有的命令就都会被执行,即使此后客户端断线也没关系,因为Redis中已经记录了所有要执行的命令。

除此之外,Redis的事务还能保证一个事务内的命令依次执行而不被其他命令插入。也就是说,在事务执行期间,服务器不会中断事务而改去执行其他客户端的命令请求,即不会被其它命令插入,不许加塞,等事务中的所有命令都执行完毕才去处理其他客户端的命令请求。即一次性、顺序性、排他性的执行一系列命令。

2、Redis事务的特性:

(3)原子性:Redis的原子性只能保证批量操作的一次性执行,和传统mysql事务不同的是,Redis不支持回滚,在执行EXEC命令时,如果Redis事务中某条命令执行失败,其后的命令仍然会被执行,没有回滚。

Redis为什么不支持回滚rollback?

  • Redis 操作失败的原因只可能是语法错误或者错误的数据类型操作,这些都是在开发期间能发现的问题,不会进入到生产环境,因此不需要回滚。
  • Redis 内部设计推崇简单和高性能,支持事务回滚能力会导致设计复杂,这与Redis的初衷相违背,因此不需要回滚能力。
  • Redis 的应用场景明显不是为了数据存储的高可靠与强一致性而设计的,而是为了数据访问的高性能而设计,设计者为了简单性和高性能而部分放弃了原子性。

(2)隔离性:事务是一个单独的隔离操作,没有隔离级别的概念,事务队列中的命令在没有提交之前都不会实际的被执行。在事务中,所有命令都会被序列化,按顺序地执行。事务在执行的过程中,其他客户端发送来的命令请求不会插入到事务执行命令序列中。

(3)持久性:如果Redis运行在某种特定的持久化模式下时,事务也具有持久性。

3、Redis事务的错误处理:

如果一个事务中的某个命令执行出错,Redis会怎样处理呢?要回答这个问题,首先需要知道什么原因会导致命令执行出错。

(1)语法错误:

语法错误指命令不存在或者命令参数的个数不对。比如:

redis>MULTI
OK
redis>SET key value
QUEUED
redis>SET key
(error)ERR wrong number of arguments for 'set' command
redis> errorCOMMAND key
(error) ERR unknown command 'errorCOMMAND'
redis> EXEC
(error) EXECABORT Transaction discarded because of previous errors.

跟在MULTI命令后执行了3个命令:一个是正确的命令,成功地加入事务队列;其余两个命令都有语法错误。而只要有一个命令有语法错误,执行EXEC命令后Redis就会直接返回错误,连语法正确的命令也不会执行。

这里需要注意一点:

Redis 2.6.5之前的版本会忽略有语法错误的命令,然后执行事务中其他语法正确的命令。就此例而言,SET key value会被执行,EXEC命令会返回一个结果:1) OK。

(2)运行错误:

运行错误指在命令执行时出现的错误,比如使用散列类型的命令操作集合类型的键,这种错误在实际执行之前Redis是无法发现的,所以在事务里这样的命令是会被Redis接受并执行的。如果事务里的一条命令出现了运行错误,事务里其他的命令依然会继续执行(包括出错命令之后的命令),示例如下:

redis>MULTI
OK
redis>SET key 1
QUEUED
redis>SADD key 2
QUEUED
redis>SET key 3
QUEUED
redis>EXEC
1) OK
2) (error) ERR Operation against a key holding the wrong kind of value
3) OK
redis>GET key
"3"

可见虽然SADD key 2出现了错误,但是SET key 3依然执行了。

Redis可能出现的问题

一、缓存雪崩:

1、什么是缓存雪崩:

如果缓在某一个时刻出现大规模的key失效,那么就会导致大量的请求打在了数据库上面,导致数据库压力巨大,如果在高并发的情况下,可能瞬间就会导致数据库宕机。这时候如果运维马上又重启数据库,马上又会有新的流量把数据库打死。这就是缓存雪崩。

2、问题分析:

造成缓存雪崩的关键在于同一时间的大规模的key失效,为什么会出现这个问题,主要有两种可能:第一种是Redis宕机,第二种可能就是采用了相同的过期时间。搞清楚原因之后,那么有什么解决方案呢?

3、解决方案:

(1)事前:

① 均匀过期:设置不同的过期时间,让缓存失效的时间尽量均匀,避免相同的过期时间导致缓存雪崩,造成大量数据库的访问。

② 分级缓存:第一级缓存失效的基础上,访问二级缓存,每一级缓存的失效时间都不同。

③ 热点数据缓存永远不过期。

永不过期实际包含两层意思:

  • 物理不过期,针对热点key不设置过期时间
  • 逻辑过期,把过期时间存在key对应的value里,如果发现要过期了,通过一个后台的异步线程进行缓存的构建

④ 保证Redis缓存的高可用,防止Redis宕机导致缓存雪崩的问题。可以使用 主从+ 哨兵,Redis集群来避免 Redis 全盘崩溃的情况。

(2)事中:

① 互斥锁:在缓存失效后,通过互斥锁或者队列来控制读数据写缓存的线程数量,比如某个key只允许一个线程查询数据和写缓存,其他线程等待。这种方式会阻塞其他的线程,此时系统的吞吐量会下降

② 使用熔断机制,限流降级。当流量达到一定的阈值,直接返回“系统拥挤”之类的提示,防止过多的请求打在数据库上将数据库击垮,至少能保证一部分用户是可以正常使用,其他用户多刷新几次也能得到结果。

(3)事后:

① 开启Redis持久化机制,尽快恢复缓存数据,一旦重启,就能从磁盘上自动加载数据恢复内存中的数据。

二、缓存击穿:

1、什么是缓存击穿:

缓存击穿跟缓存雪崩有点类似,缓存雪崩是大规模的key失效,而缓存击穿是某个热点的key失效,大并发集中对其进行请求,就会造成大量请求读缓存没读到数据,从而导致高并发访问数据库,引起数据库压力剧增。这种现象就叫做缓存击穿。

2、问题分析:

关键在于某个热点的key失效了,导致大并发集中打在数据库上。所以要从两个方面解决,第一是否可以考虑热点key不设置过期时间,第二是否可以考虑降低打在数据库上的请求数量。

3、解决方案:

(1)在缓存失效后,通过互斥锁或者队列来控制读数据写缓存的线程数量,比如某个key只允许一个线程查询数据和写缓存,其他线程等待。这种方式会阻塞其他的线程,此时系统的吞吐量会下降

(2)热点数据缓存永远不过期。

永不过期实际包含两层意思:

  • 物理不过期,针对热点key不设置过期时间
  • 逻辑过期,把过期时间存在key对应的value里,如果发现要过期了,通过一个后台的异步线程进行缓存的构建

三、缓存穿透:

1、什么是缓存穿透:

缓存穿透是指用户请求的数据在缓存中不存在即没有命中,同时在数据库中也不存在,导致用户每次请求该数据都要去数据库中查询一遍。如果有恶意攻击者不断请求系统中不存在的数据,会导致短时间大量请求落在数据库上,造成数据库压力过大,甚至导致数据库承受不住而宕机崩溃。

2、问题分析:

缓存穿透的关键在于在Redis中查不到key值,它和缓存击穿的根本区别在于传进来的key在Redis中是不存在的。假如有黑客传进大量的不存在的key,那么大量的请求打在数据库上是很致命的问题,所以在日常开发中要对参数做好校验,一些非法的参数,不可能存在的key就直接返回错误提示。

img

3、解决方法:

(1)将无效的key存放进Redis中:

当出现Redis查不到数据,数据库也查不到数据的情况,我们就把这个key保存到Redis中,设置value="null",并设置其过期时间极短,后面再出现查询这个key的请求的时候,直接返回null,就不需要再查询数据库了。但这种处理方式是有问题的,假如传进来的这个不存在的Key值每次都是随机的,那存进Redis也没有意义。

(2)使用布隆过滤器:

如果布隆过滤器判定某个 key 不存在布隆过滤器中,那么就一定不存在,如果判定某个 key 存在,那么很大可能是存在(存在一定的误判率)。于是我们可以在缓存之前再加一个布隆过滤器,将数据库中的所有key都存储在布隆过滤器中,在查询Redis前先去布隆过滤器查询 key 是否存在,如果不存在就直接返回,不让其访问数据库,从而避免了对底层存储系统的查询压力。

img

如何选择:针对一些恶意攻击,攻击带过来的大量key是随机,那么我们采用第一种方案就会缓存大量不存在key的数据。那么这种方案就不合适了,我们可以先对使用布隆过滤器方案进行过滤掉这些key。所以,针对这种key异常多、请求重复率比较低的数据,优先使用第二种方案直接过滤掉。而对于空数据的key有限的,重复率比较高的,则可优先采用第一种方式进行缓存。

四、缓存预热:

1、什么是缓存预热:

缓存预热是指系统上线后,提前将相关的缓存数据加载到缓存系统。避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题,用户直接查询事先被预热的缓存数据。

如果不进行预热,那么Redis初始状态数据为空,系统上线初期,对于高并发的流量,都会访问到数据库中, 对数据库造成流量的压力。

2、缓存预热解决方案:

(1)数据量不大的时候,工程启动的时候进行加载缓存动作;

(2)数据量大的时候,设置一个定时任务脚本,进行缓存的刷新;

(3)数据量太大的时候,优先保证热点数据进行提前加载到缓存。

五、缓存降级:

缓存降级是指缓存失效或缓存服务器挂掉的情况下,不去访问数据库,直接返回默认数据或访问服务的内存数据。降级一般是有损的操作,所以尽量减少降级对于业务的影响程度。

在项目实战中通常会将部分热点数据缓存到服务的内存中,这样一旦缓存出现异常,可以直接使用服务的内存数据,从而避免数据库遭受巨大压力。

Redis和Mysql 保持数据强一致性

1. 延时双删策略

伪代码

redis.del(key);
db.update(data);
Thread.sleep(100);
redis.del(key);
1234

这个延时的时间,需要根据具体业务中,读库的时间进行确定,还需要考虑到数据库及redis 主从同步的时间;
优点: 操作比较简单,一定程度可以保证缓存和db 数据一致性;
缺点: 在休眠时间内数据存在不一致,而且又增加了写请求的耗时。

2. 基于mysql binlog 日志进行异步更新缓存

基于mysql binlog 的日志进行分析,mysql 的操作都会记录在binlog 日志中,所以进行分析binlog日志中的行为,然后根据不同的行为及业务进行异步更新缓存,可以保证redis 缓存和mysql 数据库的强一致性;
目前mysql binlog 分析工具挺多:
开源工具:mysql-binlog-connector-java
Ali Canal: Canal

全部评论

相关推荐

码农索隆:卡学历都不行了,开始卡颜值了
点赞 评论 收藏
分享
Rena1ssanc...:对的,要是面评没太烂,勤更新简历等捞就行了,腾讯可以无限复活
点赞 评论 收藏
分享
评论
1
3
分享

创作者周榜

更多
牛客网
牛客网在线编程
牛客网题解
牛客企业服务