Java 基础篇

Java基础篇

  1. 说说什么是SaaS

SaaS也就是常说的软件及服务,是一种软件交付模型,SaaS不向用户交付最终的软件产品,软件作为用户使用的服务而存在。它就相当于软件中的租借而非购买。

也就是说,我只需要能连接上互联网,并且给saas平台交租金,我就能用saas平台给我提供的服务。

  1. SaaS数据隔离方案有哪些
  1. 、为每个租户提供一个独立的数据库系统

实现方式是所有租户共享同一个应用,但应用后端会连接多个数据库系统,一个租户单独使用一个数据库系统。这种方案的用户数据隔离级别最高,安全性最好,租户间的数据能够实现物理隔离。但成本较高。

  1. 、每个租户提供一个表空间

就是所有租户共享同一个应用,应用后端只连接一个数据库系统,所有租户共享这个数据库系统,每个租户在数据库系统中拥有一个独立的表空间。

  1. 、按照租户的id区分租户

这种方案是多租户方案中最简单的设计方式,即在每张表中都添加一个用于区分租户的字段(如租户id或租户代码)来标识每条数据属于哪个租户,其作用很像外键。当进行查询的时候每条语句都要添加该字段作为过滤条件,其特点是所有租户的数据全都存放在同一个表中,数据的隔离性是最低的,完全是通过字段来区分的。

  1. 说说HashMap的底层实现

在JDK1.8之前

HashMap是数组 + 链表结合在一起使用也就是 散列表。HashMap key的hashCode经过扰动函数处理过后得到hash值,然后通过(n -1) & hash判断当前元素存放的位置(n指的是数组长度),如果当前位置存在元素的话,就判断元素与要存入的元素的hash值以及key是否相等,如果相同的话直接覆盖,不相同就通过拉链法解决冲突。

所谓的扰函数指的就是HashMap的hash方法,使用扰动函数可以减少hash碰撞。

所谓的拉链法就是 将链表和数组相结合,也就是说创建一个链表数组,数组中的每一格就是一个链表,如果遇到hash冲突,则将冲突的值加到链表中即可。

JDK1.8之后

相比较于JDK1.8之前,JDK1.8之后的HashMap在解决冲突时有了较大的变化,当链表的长度大于阈值(默认为8)之后就会将链表转换为红黑树,如果当前数组的长度小于64,那么会选择先进行数组扩容,而不是转换为红黑树,链表转换为红黑树以减少搜索的时间。

HashMap的长度为什么是2的幂次方?

为了能够让HashMap存取高效,尽量减少碰撞,也就是要尽量的把数据分配均匀,所以使用了2的幂次方。

HashMap多线程操作导致死循环问题

主要原因就是在于并发下的Rehash会造成元素空间形成一个循环链表,不过JDK1.8版本之后解决了这个问题,但是还是不建议在多线程下使用HashMap,因为多线程下的HashMap还是会存在其他问题比如数据丢失,并发下推荐使用CurrentHashMap,这个是线程安全的。

  1. 说说HashMap的扩容机制

(1)、HashMap1.8扩容时会首先检测数组元素的个数,因为loadFactor的默认值是0.75,它含有的桶的数量默认是16,它的阈值是 16 * loadFactor,当它哈希桶占用的容量大于12的时候,就会触发扩容。就把数组的大小扩展为 2 * 16 = 32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知了HashMap的个数,那么最好就提前预设元素的个数能够有效的提高性能,

(2)、如果当某个桶中的链表长度达到8的进行链表扭转为红黑树的时候,会检查总的桶数是否小于64,如果总桶数小于64也会进行扩容。

(3)、当new完HashMap之后,第一次往HashMap进行put操作时,首先会进行扩容

  1. 说说数据库三大范式

第一范式:每一列都必须是原子性的

第二范式:首先必须满足第一范式,并且所有非主属性都完全依赖于主码

第三范式:满足第二范式,所有非主属性对任何候选关键字都不存在依赖传递。也就是说每个属性和主键都有直接关系而不是间接关系。

  1. 链表和数组

数组简单易用,在实现上使用的是连续的内存空间,这样就可以借助CPU的缓存机制,预读数组中的数据,所以访问效率效率很高,而链表在内存中并不是连续存储的,无法利用CPU的缓存机制,没有办法有效预读。

如果你的代码对内存的使用非常苛刻,那么数组就更适合你,因为链表中的每个节点都需要消耗额外的内存空间去存储一份指向下一个节点的指针,所以内存消耗会翻倍,而且对链表频繁的插入和删除操作,还会导致频繁的内存申请和释放,容易造成内存碎片,Java就会导致频繁的GC。

  1. 如何实现一个LRU缓存淘汰算法

维护一个有序单链表,越靠近链表尾部的节点就是越早之前访问的。当有一个新的数据被访问的时候,我们从链表头开始顺序遍历链表。

如果此数据没有在缓存链表中,又可以分为两种情况

  1. 、如果此时的缓存未满,,直接将新的数据插入到链表的头。
  2. 、如果此时缓存已满,则链表尾节点删除,将新的数据节点插入到链表头部。

但是这种思路时间复杂度会比较高。

如何优化?

引入Hash,来记录每个数据的位置,将换粗访问的时间复杂度直接降到O(1),

使用双向链表

可以通过 O(1) 的时间淘汰掉双向链表的尾部,每次新增和访问数据,都可以通过 O(1)的效率把新的节点增加到对头,或者把已经存在的节点移动到队头。

  1. HashMap 的PUT过程

HashMap的put操作做了什么?

HashMap的是由数组和链表构成的,JDK7之后加入了红黑树处理哈希冲突。put操作的步骤是这样的:

根据key值计算出哈希值作为数组下标。如果数组的这个位置是空的,把k放进去,put操作就完成了。

如果数组位置不为空,这个元素必然是个链表。遍历链表逐一比对value,如果value在链表中不存在,就把新建节点,将value放进去,put操作完成。

如果链表中value存在,则替换原节点的value,put操作完成。

如果链表节点数已经达到8个,首先判断当前hashMap的长度,如果不足64,只进行resize,扩容table,如果达到64就将冲突的链表为红黑树。

  1. HashMap的get过程

get方法调用

1、当调用get方法时会调用hash函数,这个hash函数会将key的hashCode值返回,返回的hashcode与entry数组长度-1进行逻辑与运算得到一个index值,用这个index值来确定数据存储在entry数组当中的位置。

2、通过循环来遍历索引位置对应的链表,初始值为数据存储在entry数组当中的位置,循环条件为entry对象不为null,改变循环条件为entry对象的下一个节点。

3、如果hash函数得到的hash值与entry对象中key的hash值相等并且entry对象当中的key值与get方法传进来的key值equals相同则返回entry对象的value值,否则返回null。

  1. MySQL的B+树相关

比如说user_name是个索引,当执行该SQL:select * from user_info where `user_name` = 'xiaoming'; InnoDB 就会建立 user_name 索引 B+树,节点里存的是 user_name 这个 KEY,叶子节点存储的数据的是主键 KEY。注意,叶子存储的是主键 KEY!拿到主键 KEY 后,InnoDB 才会去主键索引树里根据刚在 user_name 索引树找到的主键 KEY 查找到对应的数据。

主键索引树的叶子结点是直接存储数据的。

因为 InnoDB 需要节省存储空间。一个表里可能有很多个索引,InnoDB 都会给每个加了索引的字段生成索引树,如果每个字段的索引树都存储了具体数据,那么这个表的索引数据文件就变得非常巨大(数据极度冗余了)。从节约磁盘空间的角度来说,真的没有必要每个字段索引树都存具体数据,通过这种看似“多此一举”的步骤,在牺牲较少查询的性能下节省了巨大的磁盘空间,这是非常有值得的。

  1. HashMap原理详解

JDK7

数组 + 链表

比如HashMap的put,map.put(“张三”,20)

存放到数组里面就是一组 [key: 张三,value 20,hash 43545,next:null]

HashMap实现了MapEntry这个接口,HashMap的扩容机制和HashSet完全一样

HashMap的扩容机制

  1. 、 HashMap的底层维护了一个Node类型的数组table,默认值为null
  2. 、当创建对象时,将加载因子初始化为0.75
  3. 、当添加key-val时,计算出key的hash值,通过key的hash值得到在table上的索引,然后判断该索引处是否有元素,如果索引里面没有元素,则直接添加,如果索引处有元素,继续判断索引处的key是否准备加入的key相等,如果相等,则直接替换,如果不相等,则需要判断是树结构还是链表结构,做出相应处理。如果添加时发现容量不够,则需要扩容。
  4. 、第一次添加,则需要扩容table容量为16,临界值为12(16 * 0.75 = 12)
  5. 、以后再扩容则需要扩容table容量为原来的2倍,也就是32,临界值为原来的2倍,也就是24,后面的以此类推。如果链表的元素大于等于 7(treeify_thresshold - 1),那么也会进行扩容,将table的容量扩大到原来的两倍。但是链表还不会变为树
  6. 、Java8中,如果一个链表的元素个数超过treeify_threshold (8) -1,并且table的大小 >= min_treeify_capacity(默认64)就会进行树化。转换为红黑树。

更具体的回答

请你说说HashMap的put过程以及扩容机制

  • HashMap底层维护了一个Node类型的table数组,初始值为Null
  • 创建HashMap时,首先执行HashMap的构造方法,初始化加载因子为0.75
  • 执行put方法,添加key-value,put方法对key进行hash计算,调用putVal方法并将hash(key)和value作为参数
  • 在putVal方法中,判断table是否等于null或者table的长度是否等于0,如果是则调用resize()方法进行扩容,首次扩容table的大小为16,临界值为0.75 * 16 = 12。
  • 如果table已经有数据,则根据传入的hash值取出对应table索引位置的Node,如果Node为null,则新建一个Node(里面存放hash,key,value,next),添加到该索引的位置。否则说明传入的key的hash值已经存在,判断传入的hash值和索引位置hash值是否相等以及传入的key是否和索引位置的key相等,两者都满足的话,则直接替换value。否则如果当前的table已有的Node是红黑树,则调用putTreeVal()方法按照红黑树的方式去处理。否则如果当前table的Node后面是链表,那么就循环比较。
  • 循环比较:判断当前Node的next是否等于null,是则调用newNode()方法新建一个Node。然后继续判断链表的元素个数是否大于等于7(TREEIFY_THRESHOLD - 1), 满足则调用treeifyBin()方法转换为红黑树,但不是立即转换,进入这个方法首先会判断当前table是否等于null以及table长度是否小于64,如果是则调用resize()方法扩容,如果不是,则转换为红黑树。
  • 如果在循环过程中发现准备加入的数据的hash和当前Node的hash相同并且key也相同,那么直接break并且替换value
  • 每添加一个Node,就会给size加1(size表示的是table中实际的元素个数),当size的值大于threshold临界值,就进行扩容

  1. HashMap什么时候使用红黑树

在Java8中,当一个链表的元素超过了treeify_threshold (8) -1,并且table的大小大于64时就会进行树化,转换为红黑树。

  1. 红黑树和链表的区别

  1. HashMap和CurrentHashMap的区别

ConcurrentHashMap对整个桶数组进行了分割分段(Segment),然后在每一个分段上都用lock锁进行保护,相对于HashTable的synchronized关键字锁的粒度更精细了一些,并发性能更好,而HashMap没有锁机制,不是线程安全的。

HashMap的键值对允许有null,但是ConCurrentHashMap都不允许

  1. HashMap和HashTable的区别

HashTable和HashMap采用相同的存储机制,二者的实现基本一致,不同的是:

(1)HashMap是非线程安全的,HashTable是线程安全的,内部的方法基本都经过synchronized修饰。

(2)因为同步、哈希性能等原因,性能肯定是HashMap更佳,因此HashTable已被淘汰。

(3) HashMap允许有null值的存在,而在HashTable中put进的键值只要有一个null,直接抛出NullPointerException。

(4)HashMap默认初始化数组的大小为16,HashTable为11。前者扩容时乘2,使用位运算取得哈希,效率高于取模。而后者为乘2加1,都是素数和奇数,这样取模哈希结果更均匀。

  1. HashMap为什么是线程不安全的呢?

1.在JDK1.7中,当并发执行扩容操作时会造成环形链(死循环)和数据丢失的情况。

2.在JDK1.8中,在并发执行put操作时会发生数据覆盖的情况。

JDK1.7线程不安全主要体现在transfer函数中。

Transfer这段代码是HashMap的扩容操作,重新定位每个桶的下标,并采用头插法将元素迁移到新数组中。头插法会将链表的顺序翻转,这也是形成死循环的关键点。

JDK1.8线程不安全主要体现在putVal方法中

判断是否存在hash碰撞的那段代码

假设两个线程A、B都在进行put操作,并且hash函数计算出的插入下标是相同的,当线程A执行完第六行代码后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。

  1. HashTable和ConcurrentHashMap的区别

它们都可以用于多线程的环境,但当HashTable数据增大到一定程度的时候,由于很多地方都使用了synchronized修饰,性能会急剧下降,因为迭代时需要被锁定很长的时间。

HashTablde任何操作都会把整个实例对象锁住,是阻塞的。好处是:总能获取到最实时的更新,比如线程A调用putAll写入大量的数据,期间线程B调用get,那么线程B就会阻塞,直到线程A完成putAll(),因此线程B肯定能获取到线程A的完成数据。坏处是所有的调用都需要排队,效率较低。

ConcurrentHashMap设计为非阻塞的,在更新时局部锁住某部分数据,但不会吧把整个表都锁住,同步读取操作则是完全非阻塞的,好处是保证合理的同步前提下,效率很高,坏处是:严格来说,读取操作不能保证反映最近的更新,例如线程A调用putAll写入大量的数据,期间线程B调用get读取数据,则只能get到目前为止已经顺利插入的部分数据。

JDK8的版本,与JDK6的版本有很大差异。实现线程安全的思想也已经完全变了,它摒弃了它同时期的HashMap版本的思想,底层依然由数组+链表+红黑树的方式思想,但是为了做到并发,又增加了很多复制类,例如TreeBin、Traverser等对象内部类。CAS算法实现无锁化的修改至操作,他可以大大降低锁代理的性能消耗。这个算法的基本思想就是不断地去比较当前内存中的变量值与你指定的一个变量值是否相等,如果相等,则接受你指定的修改的值,否则拒绝你的操作。因为当前线程中的值已经不是最新的值,你的修改很可能会覆盖掉其他线程修改的结果。

Jvm知识篇

  1. Jvm的整体结构是什么

HotSpot VM是目前市面上高性能虚拟机的代表作之一。它采用解释器与编译器并存的架构。

程序计数器:当前线程所执行的字节码的行号指示器,用于存储指向下一条指令的地址,由执行引擎读取下一条指令。分支、循环、跳转、异常处理、线程恢复等功能都需要依赖它来完成,生命周期与线程的生命周期保持一致。线程私有。唯一一个不会出现内存溢出的区域

Java栈:存放基本数据类型、对象的引用、方法出口等、线程私有

本地方法栈:和虚拟机相似、只不过它服务于Native方法,线程私有

Java堆:内存最大块,所有对象实例、数组都存放在Java堆,GC回收的地方,线程共享

方法区(HotSpot独有):存放已经被加载的类信息、常量、静态变量、即时编译后的代码,回收目标主要是常量池的回收和类型的卸载,线程共享

如果我们一个进程中有五个线程、则就会有五个本地方法栈、虚拟机栈、程序计数器,一起共享一个方法区和一个堆空间(Heap)

  1. 说说Java代码的执行流程

  1. 说说Jvm的生命周期

虚拟机的启动

虚拟机的启动是通过引导类加载器创建一个初始类来完成的,这个类是由虚拟机的具体实现指定的。

虚拟机的执行

一个运行中的Java虚拟机有着一个清晰的任务:执行Java程序。

程序开始执行时他才运行,程序结束时它就停止。

执行一个所谓的Java程序的时候,真真正正在执行的是一个叫做Java虚拟机的进程

虚拟机的退出

程序正常执行结束。

程序在执行过程中遇到了异常或错误而异常终止。

由于操作系统错误而导致Java虚拟机终止。

某线程调用Runtime类或者System类的exit方法,或Runtime类的halt方法。

  1. 类的加载过程

一个Java文件从编码完成到最终执行,一般就经历以下两个过程

编译:将Java文件通过Javac命令编译成字节码文件,也就是我们看到的.class文件

运行:将字节码文件交给Jvm执行

加载(Loading)

通过一个类的全限定名获取此类的二进制字节流,将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构,在内存中生成一个代表这个类的Class对象,作为方法区这个类的各种数据的访问入口。

所有能够被Java虚拟机识别的有效的字节码文件的开头都是 “CA FE BA BE”

链接(Linking)

  1. 验证:目的在于确保Class文件的字节流中包含信息符合当前虚拟机的要求,保证被加载类的正确性,不会危害虚拟机自身安全,所有能够被Java虚拟机识别的有效字节码文件的开头都是”CA FE BA BE”,主要包括四种验证:文件格式验证、元数据验证、字节码验证、符号引用验证。
  2. 准备: 为类变量分配内存并且设置该类变量的默认初始值,即零值,比如private int a = 1,在这个阶段是被赋值为0的,到了初始化阶段才被赋值为1
  3. 解析: 将常量池内的符号引用转换为直接引用的过程

初始化(Initialization)

初始化阶段就是执行类构造器方法的过程,此方法不需要定义,是Javac编译器自动收集类中的所有变量的赋值动作和静态代码块中的语句合并而来的,只对static修饰的变量或者语句进行初始化。如果初始化一个类时,其父类尚未初始化,则先初始化父类。如果同时包含多个静态变量或者静态语句,则按照顺序执行。

  1. 为什么需要自定义类加载器

自定义加载器:

隔离加载类、修改类加载的方式、扩展加载源、防止源码泄漏

如何实现:

  1. 、可以通过继承抽象类ClassLoader的方式,实现自己的类加载器,以满足一些特殊的需求。
  2. 、在jdk1.2之后,不再建议用户去覆盖loadClass()方法,而是建议把自定义类的加载逻辑写在findClass()方法中。
  3. 、在编写自定义类加载器时,如果没有太过复杂的需求,可以直接继承URLClassLoader类,这个就可以避免自己去编写findClass()方法及其获取字节字节码流的方式,使自定义类加载器编写更加简洁。

  1. 什么是双亲委派机制

Java虚拟机对class文件采用的是 按需加载 的方式,也就是说当需要用到该类时才会去加载它的class文件到内存并生成class对象。而且加载某个类的class文件时,Java虚拟机采用的是 双亲委派机制,即把请求交由父类处理,它是一种任务委派模式。

  1. 双亲委派机制的工作原理

①如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行。

②如果父类的加载器还存在其父类的加载器,则进一步向上委托依次递归,请求最终达到顶层的启动类加载器

③如果父类加载器就可以完成类加载,则成功返回,倘若父类加载器无法完成此类加载任务,子类加载器才会尝试自己去加载,这就是双亲委派机制

例如我们自己在java.lang包下面新建一个String类,在自定义的String中创建一个main方法,然后执行main方法,会提示 “在类java.lang.String中找不到main方法,请将main方法定义为......”,这就说明压根就没去加载我们自定义的String类,而是交给父类加载器去加载了核心API里面的String类。(这也就是沙箱安全机制

  1. 双亲委派机制的优点
  1. 防止类被重复加载
  2. 保护程序安全,防止核心API被随意篡改

  1. 打破双亲委派机制的三种情况
  1. 重写loadClass()
  2. 使用线程上下文类加载器

  1. 什么是沙箱安全机制

比如自定义String类,但是在加载自定义Stirng类时会率先使用引导类加载器加载,而引导类加载器在加载过程中会先加载JDK自带的文件。这样可以保证核心API的安全。这就是沙箱安全机制。

  1. 从虚拟机的角度看线程

线程是一个程序里的运行单元。Jvm允许一个应用有多个线程并行的执行,在HotspotJVM里,每个线程与操作系统的本地线程直接映射。当一个Java线程准备好执行以后,此时一个操作系统的本地线程也会同时创建。Java线程销毁,本地线程也会随着回收。操作系统负责所有线程的安排调度到任何一个可用的CPU上,一旦本地线程初始化成功,它就会调用Java线程中的run方法。

Hotspot Jvm中的线程有哪些?

  1. 、虚拟机线程
  2. 、周期任务线程:时间周期事件的体现,一般用于周期性操作的调度执行
  3. 、GC线程:这种线程主要是为不同类型的垃圾收集行为提供了支持
  4. 、编译线程:会将字节码编译成本地代码
  5. 、信号调度线程:这种线程接收信号并发送给Jvm

  1. 使用PC寄存器存储字节码指令地址有什么用呢?

因为CPU在不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行。

比如有A、B两个线程,执行A到一半的时候,停止并且去执行B线程了,那么这个时候就需要记录下A线程的字节码指令地址,等到B线程执行完毕之后在回来执行A线程。

  1. PC寄存器为什么会被设定为线程私有的呢?

所谓的多线程并发在一个特定的时间段内只会执行其中某一个线程的方法。由于CPU会不停的做任务切换,这样必然会导终端或者是恢复。如何保证分毫不差呢,为了能够准确的记录各个线程正在执行的当前字节码指令地址,最好的办法自然就是为每个线程都分配一个PC寄存器,这样一来各个线程之间便可以独立计算,而不会出现相互干扰。

  1. 并行和并发的区别是什么?

并行:多个线程在多个不同的CPU上同时执行,真正意义上的同时执行

并发:多个线程任务在一个CPU上迅速的切换运行,由于速度非常快,给人的感觉就是一起执行,但其实只是逻辑意义上的同时执行。

  1. 虚拟机栈的组成部分有哪些?

局部变量表、操作数栈、动态链接、方法出口信息

局部变量表包括:编译期可知的各种数据类型、引用类型

追问1:什么是线程安全的?什么是线程不安全的?

答1:如果只有一个线程操作此数据,必须是线程安全的。

      如果有多个线程操作此数据,则此数据是共享的,如果不考虑同步机制的话,就会存在线程安全问题。那就是线程不安全的。

追问2:方法中的局部变量是线程安全的吗?

答2:如果说局部变量在方法内创建、并且在方法内消亡,就是线程安全的

如果返回到外部则线程就是不安全的。

  1. 虚拟机栈会出现哪两种错误
  1. StackOverflowError(栈溢出):Java虚拟机栈的内存大小不允许动态扩容,那么当线程请求栈的深度超过了Java虚拟机栈的深度,就会出现StackOverflowError错误
  2. OutofMemoryError(堆溢出):假设Java虚拟机栈的内存大小可以动态扩展,动态扩容时Java虚拟机栈申请不到足够的内存时并会报出OutofMemoryError错误

  1. 举例栈溢出的情况?(StackOverflowError)

比如递归陷入死循环(没有设置好递归头和递归尾)

可以通过-Xss设置栈的大小

追问1:调整栈大小,就能保证不出现溢出吗?

答1:不能,比如本身就是一个死循环,那依旧会发生栈溢出。

追问2:垃圾回收是否会涉及到虚拟机栈

答2:不会涉及GC

追问3:分配的栈内存越大越好吗?

答3:不是,内存空间是有限的,不能盲目分配。

  1. 所有的Jvm都支持本地方法栈吗?

并不是所有的Jvm都支持本地方法,因为Java虚拟机规范并没有明确的规定本地方法栈的使用语言、具体实现方式、数据结构等。

在HotspotJVM中,直接将本地方法栈和虚拟机栈合二为一。

本地方法被执行的时候,在本地方法栈中也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息等。方法执行完毕后相应的栈帧也会出栈并释放内存空间。

  1. 堆的基本概述
  1. 一个Jvm实例只存在一个堆内存,堆也是Java内存管理的核心区域,Java堆在Jvm启动的时候即被创建,其空间大小也就确定了,是Jvm管理的最大一块内存空间。堆可以处于物理上不连续的内存空间中,但是逻辑上连续的内存空间中。是线程共享的。所有的对象实例数组都应当在运行时分配在堆上。几乎所有的对象实例都在这里分配内存。
  2. 在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾回收(GC)的时候才会被移除。
  3. 栈帧中保存了对象实例的引用地址,这个引用指向堆(Heap)中实际存放的对象实例。
  4. 堆(Heap)是GC的重点区域

追问:堆空间都是线程共享的吗?

答:不都是,比如缓冲区就是线程私有的(TLAB),每个线程有一份,这样并发性能会好很多。

  1. Jvm是什么时候启动的?

当我们把程序运行起来的时候,Jvm实例就通过一个叫做bootstrap的引导类加载器将Jvm运行起来了。这就是大致的过程。

  1. 堆的内存细分

Java7及之前堆内存逻辑上分为三部分:新生区、养老区、永久区

Java8及之后堆内存逻辑上分为三部分:新生区、养老区、元空间

  1. 堆空间大小的设置

分Java堆用于存储Java对象实例,那么堆的大小在Jvm启动时就已经设定好了,可以通过选项”-Xmx” 和 “-Xms”来进行设置

“-Xms”: 表示的是堆区的起始内存 -X是Jvm的运行参数 ms是启始内存

“-Xmx”:表示的是堆区的最大内存

一旦堆区的内存代销超过了 “-Xmx”的所指定的最大内存时,将会抛出OutOfMemoryError异常。

通常将 “-Xms”和”-Xmx”两个参数配置相同的值,其目的就是为了能够在Java垃圾回收机制清理完堆区后不需要重新分割计算堆区的大小,从而提高性能。

默认情况下,初始内存大小:物理电脑内存大小 / 64

            最大内存大小:物理电脑内存大小 / 4

  1. 堆空间基本结构

  1. 堆GC的分类有哪些

按照收集区域分为:部分收集器、整堆收集器

部分收集器:不是完整收集Java堆的收集器,又分为

  1. 、新生代收集(MinorGC)
  2. 、老年代收集(MajorGC)
  3. 、混合收集(MixedGC)

整堆收集(Full GC): 收集整个Java堆和方法区的垃圾收集器

  1. GC触发条件

新生代GC触发条件:新生代空间不足就会触发MinorGC,这里年轻代指的是Eden代满。Survivor不满不会引发GC。MinorGC会引发STW,暂停其他用户线程,等垃圾回收结束,用户线程才恢复继续。

老年代GC触发条件:老年代空间不足时会尝试触发MinorGC,如果空间还是不足,则触发MajorGC。如果MajorGC后内存还是不足,则报错OOM。MajorGC速度比MinorGC慢10倍。

FullGC触发条件:(1)、调用System.gc(),系统会执行FullGC,但不是立即执行。(2)、老年代空间不足、(3)、方法区空间不足、(4)、通过MinorGC进入老年代平均大小大于老年代可用内存。

  1. 描述一下GC的过程
  1. 当Eden区的空间满了,Java虚拟机会自动的触发一次MinorGC,以收集新生代的垃圾,存活下来的对象,会被转移到survivor区。
  2. 大对象(需要大量的连续内存空间的Java对象,如那种很长的字符串)直接进入老年代
  3. 如果对象在Eden出生并经过一次MinorGC后依旧存活,并且被survivor容纳的话,年龄设置为1,并且每经过一次MinorGC后年龄都会+1,若年龄超过15岁,则会被转移到老年区。即长期存活的对象会进入老年态。
  4. 老年代进行GC(MajorGC),经常会伴随至少一次MinorGC
  5. 老年代满了而无法容纳更多的对象,MinorGC之后通常就会进行FullGC,FullGC会清理整个内存。

  1. GC算法有哪些

标记清除法、标记整理法、复制算法、分代收集算法

标记清除法

利用可达性去遍历内存,把存活对象和垃圾对象进行标记。标记结束后在对垃圾对象进行回收,这种效率低。会产生大量的内存碎片。

标记整理法

根据老年代的特点提出的一种标记算法,标记过程和”标记清除法”一致,但后续步骤不是对可回收对象进行清理,而是让所有存活对象都朝一端移动,然后直接清理掉边界以外的内存。

复制清除算法

用于新生代垃圾回收。将内存分为大小相同的两块,每次使用其中的一块。当这一块内存使用完毕后,就将还存活的对象复制到另一块去,再把使用的空间一次清理掉。

分代收集算法

根据各个年代的特点采用最适当的收集算法

新生代:复制清除算法

老年代:使用标记清除算法或者标记整理算法

  1. Java中哪些情况会导致内存泄漏
  1. 、大量使用static字段
  2. 、未关闭资源

比如我们使用IO流时,未关闭连接会导致留下的开放连接消耗内存,持续占有内存,如果不处理,就会降低性能,甚至OOM

  1. 、使用ThreadLocal没有显示的删除时,就会一直保留在内存中,不会被垃圾回收,所以不再使用ThreadLocal时,应该调用remove()方法,该方法删除了此变量的当前线程值。
    (4)、代码中存在死循环或者循环产生过多重复的对象实体,占用大量的内存

(5)、内存中加载的数据量对于庞大的,比如从数据库一次取出很多数据存放在内存中

  1. Java中引起内存溢出的原因有哪些?

内存泄露和内存溢出是不一样的,内存泄露所导致的越来越多的内存得不到回收时候,就会导致内存溢出。

  1. 、内存中加载的数据量过于庞大,如一次从数据库中取出过多的数据
  2. 、集合类中有对对象的引用,使用完后未清空,使得JVM不能回收
  3. 、代码中存在死循环或者循环中产生过多的重复对象实体
  4. 、启动参数内存值设定的过小

内存溢出的解决方案

  1. 、修改JVM的启动参数,直接增加内存 (-Xms,-Xmx)
  2. 、检查错误日志、查看OOM错误前是否有其他异常或者错误
  3. 、对代码进行走查和分析,找出可能发生内存溢出的位置

真实案例

MinorGC频繁,MajorGC频繁

情况:MinorGC每分钟100次 ,MajorGC每4分钟一次,单次MinOR GC耗时25ms,单次Major GC耗时的200ms,接口响应时间为50ms

解决方案

首先优化MinORGC频繁的问题,通常情况下,由于新生代空间较小,Eden区很快就被填满,就会导致频繁的MinorGC ,因此可以增大新生代空间来降低的MinorGC的频率,例如在相同的内存分配率的前提下,新生代中的Eden区增加一倍,MinorGC的次数就会减少一半。

  1. 手动调用GC的办法

Java公有API中有两种可以主动调用GC的办法
(1)、System.gc();  只是告诉JVM尽快GC一次(建议),但不会立即执行GC
(2)、Runtime.getRuntime().gc();

多线程知识篇

  1. 进程和线程之间的区别

进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的

在Java中我们启动main函数时其实就是启动了一个JVM的进程,而main函数所在的线程就是这个进程中的一个线程,也称作主线程。

线程是一个比进程等更小的执行单位,一个进程在其执行的过程中可以产生多个线程,线程也被称作轻量级的进程。

  1. 程序计数器为什么是线程私有的?

执行Java代码时,程序计数器主要用来记录的是下一条指令的地址,在多线程运行情况下,程序计数器必须记住当前线程的位置,从而当线程被来回切换时能够知道该线程上次运行到哪里了。所以必须是线程私有的。这样切换后才能恢复到正确的执行位置。

  1. 说说并发和并行的区别?

并发:同一时间段多个任务都在执行

并行:单位时间内多个任务同时执行

  1. 使用多线程带来什么问题?

并发编程的目的是为了能提高程序的执行效率提高程序的运行速度,但是并发编程并不总是能提高程序运行速度的,而是并发编程可能遇到很多问题,比如内存泄漏、上下文切换、死锁等。

  1. 说说什么是线程安全?

线程安去就是多线程访问时,采用了加锁机制,当一个线程访问该类的某个数据时,进行保护,其他线程不能进行访问直到该线程读取完,其他线程才可使用。不会出现数据不一致或者数据污染。 线程不安全就是不提供数据访问保护,有可能出现多个线程先后更改数据造成所得到的数据是脏数据。(共享数据哦)

  1. 说说线程的生命周期和状态
  1. 、new:初始化,线程被构建但是还没有调用start()方法
  2. 、runable:就绪状态,调用start方法后处于可运行状态,获得CPU时间段后开始运行
  3. 、running:  运行状态,CPU开始调度处于就绪状态线程时,线程才开始得以真正执行。就绪状态是进入到运行状态的唯一入口。
  4. 、blocked: 阻塞状态,由于某种原因暂时放弃对CPU的执行权。

等待阻塞:运行中的线程执行wait()方法,使得本线程进入等待阻塞状态

同步阻塞:线程在获取synchronized同步锁失败,就会进入同步阻塞状态

其他阻塞:通过调用线程的sleep()或join或者发出IO请求时

  1. 、terminated: 终止状态,线程执行完或者通过异常退出了,又或者调用stop方法停止等。但是使用stop停止线程会造成数据不一致的情况。一旦出现这样的情况,程序处理的顺序就有可能遭到破坏。

  1. 什么是上下文切换?

多线程中一般线程的个数都大于CPU核心的个数,而一个CPU核心在任一时刻只能被一个线程使用。为了让这些线程都得到有效执行,CPU采取的策略是为了每个线程分配时间片轮转的形式,当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程属于一次上下文切换。

  1. 什么是死锁,如何避免死锁?

死锁:多个线程同时被阻塞,它们中的一个或者是全部都在等某个资源被释放。由于线程被无限期的阻塞,因此程序不可能正常终止。

四大条件

  1. 、互斥条件:该资源任一一个时刻只能被一个线程占用
  2. 、请求和保持条件:一个进程因请求资源而阻塞时,对已经获得的资源保持不放
  3. 、不剥夺条件:线程已经获得的资源在未使用完之前不能被其他线程强行剥夺
  4. 、循环等待:若干线程之间形成一种头尾相接的循环等待资源的关系。

  1. 说说创建线程的几种方式
  1. 、继承Thread类 并重写它的run方法,调用start来启动线程
  2. 、实现Runable接口并实现它的run方法,调用start来启动线程,用得多,能够避免了继承Thread类的单继承局限性。
  3. 、实现Callable接口,并结合Future实现,实现call方法,call有返回值
  4. 、通过线程池创建,比如JDK自带的Executors来创建线程池对象

  1. 说说创建线程池的几种方式

线程池是一种基于池化思想管理和使用线程的机制,它是将多个线程预先存储在一个池子内,当有任务出现时可以避免重新创建和销毁线程所带来的的性能开销,只需要从池子内取出相应的线程执行对应的任务即可。如果不使用线程池,则可能导致系统创建大量同类线程而导致消耗完内存或者 “过度切换”的问题。

优点:

  1. 、降低资源消耗,避免创建和销毁线程带来的损耗和开销
  2. 、提高响应速度
  3. 、提高线程的可管理性
  4. 、提供更多更强大的功能

创建方式

  1. 、通过Executors创建
  2. 、通过ThreadPoolExecutor创建

具体创建方式

ThreadPoolExecutor包含的参数有哪些?

  1. 、corePoolSize: 核心线程数,也就是线程池中始终存活的线程数
  2. 、maximumPoolSize:最大线程数,线程池中允许的最大线程数
  3. 、keepAliveTime: 最大线程数可以存活的时间
  4. 、unit:线程存活时间的单位
  5. 、workQueue: 阻塞队列,用来存储线程池等待执行的任务
  6. 、threadFactory:线程工厂,主要用来创建线程
  7. 、handler : 拒绝策略

为什么不建议使用Executors创建线程?

(1) FixedThreadPool 和 SingleThreadPool:允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。

(2)CachedThreadPool:允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM。

  1. 说说sleep和wait方法区别和共同点
  1. 只要区别在于sleep()方法没有释放锁,而wait方法释放了锁
  2. wait方法被用于线程间的交互通信、sleep方法通常用于暂停执行
  3. Wait不会自动苏醒,需要别的线程调用一个对象上的notify或者notifyAll方法,而sleep方法执行完成后,线程会自动苏醒。

共同点:两者都可以暂停线程的执行

  1. 说说notify和notifyall的区别

notify方法可以随机唤醒等待队列中的等待同一共享资源的“一个”线程,并使得该线程退出等待队列,进入可运行状态,也就是notify方法仅通知一个线程。

notifyAll方法可以使所有正在等待队列中等待同一个共享资源的“全部”线程从等待状态退出,进入可运行状态,此时优先级最高的线程最新执行,但也有可能是随机执行。

  1. 说说synchronized关键字的理解

Synchronized关键字解决的是多个线程之间访问同一个资源的同步性,synchronized关键字可以保证被他修饰的方法或者代码块在任意时刻只能被同一个线程访问。

在Java早期版本中,synchronized属于重量级锁,效率低下,Java6后面在JVM层面对synchronized关键字进行了优化,所以现在的synchronized性能不错。

  1. 说说synchronized关键字作用域
  1. 、修饰实例方法:作用于当前对象实例加锁,进入同步代码块前要获得当前对象实例的锁
  2. 、修饰静态方法:给当前类加锁,会作用于类的所有实例对象
  3. 、修饰代码块:指定加锁对象,对给定对象/类加锁

尽量不要使用 synchronized(String a)因为Jvm中字符串常量池具有缓存功能

构造方法不能使用synchronized关键字修饰,因为本身就是线程安全的,不存在同步构造方法一说。

  1. 说说synchronized关键字的原理

同步代码块是使用monitorenter和monitorexit指令实现的,同步方法依靠的是方法修饰上的ACC_SYNCHRONIZED实现。

  1. 说说保证线程安全的方式有哪些?Synchronized 和Lock锁的区别是什么?
  1. 、synchronized关键字用来控制线程同步,当一个线程获得锁且在释放锁之前,其他线程是不可以获得这个锁并访问资源的。
  2. 、Lock,需要手动的加锁和释放锁,这相比synchronized不够便捷。释放锁的操作一般在finally中,因为finally中的代码无论如何都会执行的。

  1. 说说什么是线程安全?

当多个线程访问某个方法时,不管你通过怎样调用方式或者说如何交替的执行,我们在主线程中都不需要去任何的同步,程序依旧能够按照我们的预期执行,那么这就是线程安全。

  1. 说说Java中哪些类是线程安全的,哪些不是线程安全的?

线程安全:

  1. 、Vector
  2. 、HashTable
  3. 、StringBuffer
  4. 、ConcurrentHashMap

非线程安全的:

  1. 、ArrayList
  2. 、LinkedList
  3. 、HashMap
  4. 、HashSet
  5. 、TreeMap
  6. 、TreeSet
  7. 、StringBuider

  1. 说说synchronized关键字和volatile关键字的区别

Volatile 关键字是线程同步的轻量级实现,所以volatile性能肯定比synchronized关键字要好,

volatile只能作用于变量而synchronized关键字可以修饰方法以及代码块。

Volatile能保证数据的可见性,但是不能保证数据的原子性,synchronized两者都可以保证

Volatile 主要用于解决变量在多个线程之间的可见性,而synchronized关键字解决的是多个线程之间访问资源的同步性。

  1. 说说volatile的实现原理

作用:使用volatile修饰的成员变量,就是告知程序任何对该变量的访问均需要从共享内存中获取,而对它的改变必须同步刷新回共享内存,它能保证所有线程对变量访问的可见性。

原理:强制把修改的数据写回内存。

  1. 说说synchronized和lock锁的区别
  1. - Synchronized是一个内置的Java关键字、Lock是一个Java类
  2. - Synchronized无法判断获取锁的状态、Lock可以
  3. - Synchronized自动释放锁,Lock锁需要手动释放的,如果不释放则会发生死锁
  4. - Synchronized 线程1(获得锁、阻塞)、线程2(无尽头的等待);Lock锁就不一定会等待,可以通过lock.tryLock尝试获取锁
  5. - Synchronized是可重入锁、不可以中断的、非公平;Lock,可重入锁,可以判断锁、非公平/公平(可以自己设置)
  6. - Synchronized 适合锁少量的代码同步问题,Lock可以锁大量的代码块

  1. 说说ThreadLocal

我们创建的变量是可以被任何一个线程修改并访问的,如果想实现每一个线程都有自己的专属变量该如何解决?

可以使用ThreadLocal类,这个类主要解决的就是让每个线程绑定自己的值,ThreadLocal也就是存放数据的盒子,盒子中可以存放每个线程中私有的数据。

  1. 说说什么是自旋锁CAS

自旋锁:当一个线程在获取锁的时候,如果锁已经被其他线程获取,那么它就会循环等待,不断的判断锁是否能成功获取到,直到获取到锁才的退出循环。

  1. 自旋锁CAS有什么缺点和优点

如果某个线程持有锁的时间更长,就会导致其他等待获取锁的时间过长,消耗CPU,使用不当会造成CPU使用率极高。

自旋锁的不是公平的,即无法满足等待时间最长的线程优先获取锁,就会导致 “线程饥饿”问题。

Java中的自旋锁_孙悟空2015的博客-CSDN博客_java自旋锁

  1. 说说什么是重入锁

自己可以获取自己的内部锁,当线程请求自己持有的锁对象时,如果锁是重入锁,线程请求成功。

比如有一个线程A获取到了对象锁,此时这个对象锁还没有被释放,当其再次想要获取这个对象锁时是可以获取的,如何不可锁重入的话,就会造成死锁。

可重入锁主要是为了避免死锁的。Java的synchronized和ReentrantLock都是可重入锁。

  1. 介绍一下AQS

AQS全称是 AbstractQueuedSynchronized,这个类在java.ugtil.concurrent.locks包下面。

AQS是一个用来构建锁和同步器的框架,使用AQS能简单高效的构造出应用广泛的大量同步器,核心原理就是:如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且被共享资源设置为锁定状态,如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的资源放队列中。

  1. 介绍一下分段锁

在并发程序中,串行操作是会降低可伸缩性,并且上下文切换也会减低性能。在锁上发生竞争时将通水导致这两种问题,使用独占锁时保护受限资源的时候,基本上是采用串行方式—-每次只能有一个线程能访问它。所以对于可伸缩性来说最大的威胁就是独占锁。

我们一般有三种方式降低锁的竞争程度:

1、减少锁的持有时间

2、降低锁的请求频率

3、使用带有协调机制的独占锁,这些机制允许更高的并发性。

在某些情况下我们可以将锁分解技术进一步扩展为一组独立对象上的锁进行分解,这成为分段锁。其实说的简单一点就是:容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术,首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。

比如:在ConcurrentHashMap中使用了一个包含16个锁的数组,每个锁保护所有散列桶的1/16,其中第N个散列桶由第(N mod 16)个锁来保护。假设使用合理的散列算法使关键字能够均匀的分布,那么这大约能使对锁的请求减少到越来的1/16。也正是这项技术使得ConcurrentHashMap支持多达16个并发的写入线程。

  1. 事务的范围和锁的范围

场景描述

秒杀系统中,模拟1000个用户在一秒内并发请求下单,如果使用synchronized关键字防止超卖问题(也就是悲观锁的方式),同时加了注解@Transcation,会发生什么问题呢?该把synchronized放在哪里呢?

扣减库存正常,但是订单数量远远大于商品售卖的数量。

原因是synchronized加在了service层,同时加了@Transcation注解,也就是说在执行该方法开始时,事务启动,执行完毕后,事务关闭。但是synchronized没有起作用,其实根本原因是因为事务的范围比锁的范围大,也就是说在加锁的那部分代码执行完毕后,锁释放掉了,但是事务还没结束,此时另一个线程进来了,事务没结束的话另一个线程进来时,数据库的状态和第一个线程刚进来时是一样的,即由于MySQL存储引擎默认隔离级别是可重复读,线程2事务开始时,线程1还没提交完成,导致读取的数据还没更新,第二个线程也做了插入动作,导致了脏数据。所以在查询库存、扣减库存、新增订单一系列操作中才会发生订单数远远大于商品售卖数的情况。

解决方案

  1. 、把事务去掉,不推荐
  2. 、更改事务隔离级别为 读未提交 ,不推荐
  3. 、在调用service的地方(比如controller)加锁,保证锁的范围比事务的范围大,推荐
  4. 、在查询库存时使用当前读,比如 FOR UPDATE,读取最新的数据。

  1. Thread类中的start()和run()方法有什么区别?

Start()方法用来启动新创建的线程,而start()内部其实调用了run()方法,这和直接调用run()方法的效果不太一样,当你调用run()方法时,只会在原来的线程中调用,没有新的线程启动,而调用start()方法会启动一个新的线程。

  1. Java中如何停止一个线程?

Java中提供了丰富的API,但没有为停止线程提供API,当run或者是call方法执行完成后,线程会自动结束,如果要手动结束,可以使用volatile布尔变量来推出run方法的循环或者是取消任务来中断线程。或者是抛出异常,最好的方式就是抛出异常,抛出异常的方式可以让线程停止的事件得以传播。

  1. 一个线程运行时发生异常会怎么样?

如果异常没有被捕获则该线程会停止执行。

高并发常见的面试题_Cynthia_wpp的博客-CSDN博客_高并发面试题

  1. java中synchronized和ReentrantLock有什么不同?

通过synchronized关键字来实现互斥,它有一些缺点。比如你不能扩展锁以外的方法或者块边界,尝试获取锁时不能中途取消等。java5 通过Lock接口提供了更复杂的控制来解决这些问题。ReentrantLock类实现了Lock,它拥有与synchronized相同的并发性和内存语义且它还具有可扩展性。

  1. 有三个线程T1、T2、T3,怎么确保它们按照顺序执行

在多线程中有多重方法让线程按照特定的顺序执行,可以使用线程类的join方法在一个线程中启动另一个线程,另外一个线程完成后该线程继续执行,为确保顺序执行,你应该先启动最后一个,(T3调用T2,T2调用T1),这样T1就会先完成而T3最后完成。

MySQL知识篇

索引篇

  1. InnoDB存储引擎支持哪些类型的索引?
  1. 、B+树索引
  2. 、全文索引
  3. 、Hash索引

  1. B+树索引是直接找到给定键值的具体行吗?

找不到。B+树索引找到的只是数据行对应的页,然后数据库通过把页读入到内存,再在内存中进行查找,最后找到对应的数据行。使用的算法是 “二分查找算法”。

  1. 二叉查找树、平衡查找树的特点分别是什么?

二叉查找树:左子树的键值总是小于根的键值,右子树的键值总是大于根的键值

平衡查找树:平衡查找树由二叉查找树演变而来,特点是左子树的键值总是小于根的键值,右子树的键值总是大于根的键值、任何节点的两个子树的高度最大差为1。 平衡二叉树的插入、删除、更新操作都需要进行左旋右旋操作,因此维护一棵平衡二叉树是有一定的开销的,不过平衡二叉树多用于内存结构的对象中,因此维护的开销比较小。

  1. 简单介绍一下B+树

B+树是由B树和索引顺序访问方法演化而来的,是为了磁盘或其他直接存取辅助设备设计的一种平衡二叉树,在B+树中,所有的记录节点都是按照键值的大小顺序存放在同一层的叶子结点上的。由各个叶子结点指针进行连接。

  1. B+树为什么提供旋转功能?

在进行插入删除等操作时,为了保持平衡,B+树必须进行大量的拆分页(split)的操作,而B+树主要是用于磁盘的,split操作意味着进行磁盘操作,所以为了在可能的情况下尽可能的减少split操作,B+树加入了旋转的功能。且旋转发生在Leaf Page已经满,但是其左右兄弟节点没有满的情况。

  1. B+树索引有哪些?

聚集索引

辅助索引

两者的区别是叶子结点存放的是否是一整行的信息。

  1. 介绍一下聚集索引

聚集索引就是按照每张表的主键构造的一棵B+树,同时叶子结点中存放的是数据记录,也将聚集索引的叶子结点称为数据页,聚集索引的这个特性决定了索引组织表中的数据也是索引的一部分。每个数据页都是使用双向链表进行连接的。

多数情况下查询优化器都比较偏向于聚集索引,因此聚集索引能够在B+树索引的叶子结点上找到数据。

数据页上存放的都是完整的每行的记录,而非数据页上的索引页中存放的仅仅是键值和指向数据页的偏移量,而不是一个完整的行记录。

注意:聚集索引是逻辑上的连续,并非物理上的连续。

微服务知识篇

SpringCloudAlibaba知识篇

  1. Nacos服务注册和发现流程

服务容器负责启动,加载,运行服务提供者。

服务提供者在启动时,向注册中心注册自己提供的服务。

服务消费者在启动时,向注册中心订阅自己所需的服务。

注册中心返回服务提供者地址列表给消费者,如果有变更,注册中心将基于长连接推送变更数据给消费者。

服务消费者,从提供者地址列表中,基于软负载均衡算法,选一台提供者进行调用,如果调用失败,再选另一台调用。服务消费者和提供者,在内存中累计调用次数和调用时间,定时每分钟发送一次统计数据到监控中心。

  1. 分布式和集群的区别

分布式:是指将不同的业务分布在不同的地方,

集群:是指将几台服务器集中在一起,实现同一业务。

分布式中的每一个节点,都可以做集群,而集群并不一定就是分布式的。集群有组织性,一台服务器垮了,其它的服务器可以顶上来,而分布式的每一个节点,都完成不同的业务,一个节点垮了,哪这个业务就不可访问了。

  1. 什么是SpringCloud?

是一系列分布式框架的集合,基于SpringBoot进行开发的,将不同公司的不同组件进行集成,以SpringBoot风格进行集成开发,开发者不需要关注底层的实现,而是开箱即用,需要哪个组件就用SpringBoot来整合。

  1. 什么是SpringCloudAlibaba?

是微服务开发提供的一套一站式的分布式解决方案,包含了分布式应用服务的必需组件。使开发者通过SpringCloud编程模型轻松的解决微服务架构下的各类技术问题。

工程结构:SpringBoot -> SpringCloud -> SpringCloudAlibaba,需要版本兼容

  1. 什么是服务治理?

服务注册 + 服务发现 = 服务治理

服务注册和服务发现:Nacos

  1. 什么是Ribbon?

Ribbon不是SpringCloudAlibaba的组件,而是Netflix提供的。默认使用轮询算法。

负载均衡算法:轮询算法、随机算法、基于权重算法等

  1. 什么是雪崩效应?如何解决雪崩?

雪崩效应:指的是多个服务之间相互调用,其中一个服务的不可用导致整个系统瘫痪

解决方案:

  1. 、设置线程超时
  2. 、设置限流
  3. 、熔断器,主流的解决方案

     降级:系统将不需要的功能接口停用,主要是应对自身的

     熔断:系统将停止调用其他服务不可用的那些接口

     限流:限制并发访问数或者一个时间窗口内允许处理的请求数量来保护系统,一旦达到限制的数量就采取对应的拒绝策略。本质上就是损失一部分用户的可用性,为大部分用户提供稳定可靠的服务。

  1. 常见的限流方案有哪些?
  1. 、在Nginx层添加限流模块限制平均访问速度
  2. 、通过设置数据库连接池、线程池大小来限制总的并发数。
  3. 、通过Guava提供的Ratelimiter限制接口的访问速度。
  4. 、TCP通信协议中的流量整形。

   

  1. Sentinel做流控时使用链路模式需要注意什么?

需要注意关闭context整合,Sentinel默认会将Controller方法做context整合,导致链路模式的流控失效,需要修改application.yml

  1. Sentinel做流控时有哪些控流效果?
  1. 、快速失败:直接抛出异常
  2. 、Warm Up:会有预热时间,比如预热时间为5s,阈值为3,那么就表示的是5秒内每秒请求不能超过1次,5秒后每秒请求可以为3次,因为我们的服务器在刚启动时都会有一个预热阶段,预热阶段的性能是不行的,那么这个时候就应该限流。
  3. 、排队等待:调用失败时不会立马抛出异常,而是会等到超时时间后在调用一次,如果还调用不通,就抛出异常。
  1. Sentinel做流控时有哪些流控模式?
  1. 、直接
  2. 、关联
  3. 、链路

  1. Sentine降级解决方案有哪些?
  1. 、慢调用比例:当资源的响应时间超过最大RT之后则为慢调用,当请求数目大于设置的最小请求数目并且慢调用的比例大于阈值,则接下来的熔断时长内请求会被自动的熔断。经过熔断时长后熔断器会进入探测恢复状态,如果接下来的一个请求响应时间小于设置的慢调用RT则熔断结束,如果大于设置的RT则再次被熔断。

通俗解释:如果RT = 1s,最小请求数 = 5,就表示如果连续5次请求的响应时间都超过1S,那么就是慢调用,会降级并且抛出异常。

  1. 、异常比例:当某个方法每秒调用所获的异常总数的比例超过设定的阈值时,该资源就会自动的进入降级状态。
  2. 、异常数:每分钟内请求的异常数大于设定的阈值,则直接进入降级。

  1. 解释一下Nacos架构

服务(Service)

服务指的是一个或者一组软件功能

服务注册中心(Service Registy)

服务注册中心是服务、实例、元数据的数据,服务实例在启动时注册到服务注册表,并在关闭时注销,服务和路由器的客户端查询服务注册表以查找服务的可用实例,服务注册中心可能会调用的服务实例的健康检查API来验证它是否能够处理请求。

服务元数据(Service Metadata)

指的是服务端点、服务标签、服务版本号、服务实例权重、路由规则等

  1. SpringBootAdmin是如何发现Nacos里面的服务的。

SpringBoot Admin Server 是通过Spring Cloud DiscoveryClient来发现应用程序的,且使用了Spring Cloud Discovery之后不再需要使用Spring Boot Admin Client,只需要向管理服务器添加一个实现,其他一切由AutoConfiguration来完成。

根据DiscoveryClient接口可知,它里面有三个方法,我们的NacosDiscoveryClient 实现了 DiscoveryClient 接口,然后通过 getServices()方法获取所有的服务列表。大概就是这样的。

追问:如果我的工程配置了context-path,应该如何让Admin发现呢?

将context-path追加到Admin服务的URL中即可。(yml里配置)

  1. 为什么选择GateWay作为网关组件?它的组成是什么样的?
  1. 、为什么选择:GateWay是Spring官方最新推出的一款基于SpringBoot2之上开发的,与第一代网关组件zuul不同的是:gateway是非异步阻塞的,zuul是同步阻塞请求的。
  2. 、组成:路由(ID 和 目标URI) + 断言 + Filter过滤器

  1. GateWay的工作模型
  1. 、请求发送到网关,经由分发器将请求匹配到相应的HandlerMapping
  2. 、请求和路由器之间有一个映射,路由到网关处理程序,即WebHandler
  3. 、执行特定请求过滤链
  4. 、最终到达代理的微服务

  1. GateWay静态路由配置和动态路由配置的区别

(1)、静态路由配置:使用yml或者properties,端点是spring.cloud.gateway

缺点: 每次改动都需要把网关服务重新配置

  1. 、动态路由配置:路由信息在Alibaba Nacos中维护,可以实现动态路由:Nacos配置中心刷新routes配置信息。路由信息刷新改变。利用事件发布完成动态刷新路由,RouteDefinitionRepository 这个接口成为了关键点我们来重新动态路由其实也是基于这个接口来实现,利用Nacos的监听器原理,路由配置改变之后能够感知并刷新

  1. SpringCloud Gateway Filter是如何过滤请求的?

SpringCloudGateway是基于过滤器实现的,有pre 和 post两种方式,分别是处理前置逻辑和后置逻辑。

客户端的请求经过pre类型的filter,然后将请求转发到具体的业务,收到业务服务的响应之后,再经过post类型的filter处理,最后返回响应到客户端。

Filter两大类:全局过滤器和局部过滤器。

过滤器有优先级区分,Order越大,级别越低,越晚执行

全局过滤器:RouteToRequestUrlFilter 所有的请求都会执行

局部过滤器:PrefixPathGatewayFilterFactory(添加前缀)、StripPrefixGatewayFilterFactory(去掉前缀) 只有配置的请求才执行。

  1. 什分布式链路追踪的方案

SpringCloudSleuth: 它会自动的为当前应用构建起各通信通道的跟踪机制

例如通过RabbitMQ、Kafka传递的请求,通过Zull、Gateway传递的请求、通过Resttemplate传递的请求等。

SpringCloudSleuth实现原理

(1)、为了实现请求跟踪:当请求发送到分布式系统的入口端点时,只需要服务跟踪框架为该请求创建一个唯一的跟踪标识TracedID

(2)、为了统计各处理单元的时间延迟,当请求到达各个服务组件时,或者是处理逻辑到达某个状态时,也通过唯一标识来标记她的开始,具体过程以及结束,SpanID

Zipkin

解决微服务中的延迟问题,实现数据的收集、存储、查找和展现

四大核心组件

  1. 、Collector:收集器组件
  2. 、Storage:存储组件
  3. 、API:提供外部访问接口

  1. Feign简介

Feign是一种声明式、模板化的HTTP客户端。使用Feign,可以做到声明式调用。

尽管Feign目前已经不再迭代,处于维护状态,但是Feign仍然是目前使用最广泛的远程调用框架之一。

在SpringCloud Alibaba的生态体系内,有另一个应用广泛的远程服务调用框架Dubbo,在后面我们会接触到。

Feign是在RestTemplate 和 Ribbon的基础上进一步封装,使用RestTemplate实现Http调用,使用Ribbon实现负载均衡。

  1. Feign原理

主程序入口添加了@EnableFeignClients注解开启对FeignClient扫描加载处理。根据Feign Client的开发规范,定义接口并加@FeignClientd注解。

当程序启动时,会进行包扫描,扫描所有@FeignClients的注解的类,并且将这些信息注入Spring IOC容器中,当定义的的Feign接口中的方法被调用时,通过JDK的代理方式,来生成具体的RequestTemplate.

当生成代理时,Feign会为每个接口方法创建一个RequestTemplate。当生成代理时,Feign会为每个接口方法创建一个RequestTemplate对象,该对象封装了HTTP请求需要的全部信息,如请求参数名,请求方法等信息都是在这个过程中确定的。

然后RequestTemplate生成Request,然后把Request交给Client去处理,这里指的是Client可以是JDK原生的URLConnection,Apache的HttpClient,也可以是OKhttp,最后Client被封装到LoadBalanceClient类,这个类结合Ribbon负载均衡发起服务之间的调用。

分布式事务问题

分布式事务理论

  1. 什么是分布式事务?

事务:是一系列对系统中数据进行访问和更新的操作所组成的一个程序执行逻辑单元

分布式事务:分布式事务问题也叫做分布式数据一致性问题,简单来说就是如何在分布式场景中保证多个节点数据的一致性。分布式事务产生的核心原因在于存储资源的分布性,比如多个数据库,MySQL和Redis两种不同存储设备的数据一致性。在实际应用中我们应该尽可能的从设计层面去避免分布式事务的问题。引入某种额外的机制来协调多个事务要么全部提交、要么全部回滚,以此来保证数据的完整性。

  1. 说说什么是2PC和3PC以及他们之间的区别

2PC也叫做两阶段提交,第一阶段是事务的准备阶段,第二阶段是事务的提交或者回滚阶段。

2PC过程

两种角色:事务协调者、事务参与者

第一阶段:事务协调者向事务参与者下达 “处理本地事务”的通知,事务参与者收到通知以后开始处理本地事务,本地事务处理完毕之后回复事务协调者 “本地事务处理完毕”。当事务协调者收到处理完毕的通知后开始进入第二阶段。

注意:所谓的本地事务就是每个服务该做的事情,在这个阶段所有数据都处于未提交状态。

第二阶段:事务协调者会向事务参与者下达 “开始提交”的命令,事务参与者收到通知以后开始提交事务完成数据的最终写入,提交完毕后回复事务协调者 “提交完成”,事务协调者收到 “提交成功”的通知以后,就意味着一次分布式事务完成。

注意:假设在阶段一有任何一个服务因为某种原因向事务协调者上报 事务处理失败,就意味着整体业务处理出现问题,阶段二的操作就会改为回滚处理,将所有未提交的数据撤回,使数据还原以保证完整性

2PC的缺点:

  1. 同步阻塞:比如阶段二因为网络问题有任何一个事务参与者未收到事务协调者下达的提交命令,则未提交的数据就会长时间的被阻塞,占用的资源被锁定(数据库排它锁),最终可能导致系统的崩溃。
  2. 过于保守,任何一个节点失败都会导致所有数据回滚
  3. 单点故障:事务协调者在第二阶段出现故障,那么所有的参与者都会处于阻塞状态,占用的资源被锁定,这时如果其他请求不断涌入,就会发生系统崩溃。

那么同步阻塞的问题怎么解决呢?其实只要在服务这一侧增加超时机制,过一段时间被阻塞的事务就会自动提交,释放锁定的资源。尽管这样做会导致数据的不一致,但是也比线程积压导致服务崩溃要好一些,处于此目的,三阶段的提交(3PC)应运而生。

3PC过程

第一阶段:询问阶段。事务协调者向事务参与者询问是否可以完成事务执行,参与者只需要回答 “是”或者”否”,不需要做真正事务处理,这个阶段会有超时终止机制。

第二阶段:准备阶段。事务协调者会根据事务参与者的反馈结果决定是否被继续执行,如果在询问阶段所有参与者都回答可以执行。则事务协调者会向事务参与者下达 “执行事务”的命令,事务参与者收到命令后开始执行本地事务,执行完毕后向事务协调者回复 “事务执行完成”,事务协调者收到所有”事务执行完成”回复后,开始进入第三阶段。

第三阶段:提交阶段。事务协调者收到“事务执行完成”的反馈之后,开始下发”提交事务”的命令,事务参与者收到命令之后,开始执行commit提交事务完成数据的最终写入,写入完成后通知事务协调者 “提交完成”。反之如果有任何一方参与者返回失败的通知,则事务协调者就会发起终止命令来回滚事务。事务协调者收到所有事务参与者的通知后,一次分布式事务提交就完成了。

如果协调者服务通信中断导致无法提交,在服务端超时之后也会自动执行提交操作来保证资源的释放。加入了超时机制来保证资源不被长时间占用。

2PC和3PC的区别

  1. 、3PC增加了一个预提交阶段,用哪个有询问所有参与者是否可以提交事务并且响应,它的好处是可以尽早的发现无法执行操作的是参与者而尽早的结束后续的行为。
  2. 、在准备阶段之后,事务协调者和参与者都引入了超时机制,一旦超时,事务协调者和参与者会继续提交事务,保证资源不被锁定。

超时机制带来的问题

  1. 、超时机制的强制提交可能会导致数据一致性被破坏

超时机制带来的问题的解决方案有哪些?

  1. 、增加异步的数据补偿任务
  2. 、更完善的业务数据完整性的代码校验
  3. 、引入数据监控及时通知人工补录

总结:无论是2PC还是3PC得分布式事务方案都只是一种宏观设计,如果要落地最终还是需要依托具体的软件产品,比较有代表性的有ByteTCC,TXLCN,EasyTransaction,AlibabaSeata。

  1. 解释一下CAP理论

CAP理论又叫做布鲁尔理论,指的是在分布式环境中不可能同时满足一致性(C)、可用性(A)和分区容错性(P),最多能够同时满足两个。

C: 一致性,数据在多个节点中必须保持一致,也就是说写操作后的读操作读取到的最新数据的状态要保持一致。当数据分布在多个节点上,从任一节点读取到的数据都是最新状态。

如何实现一致性?

(1)、写入数据后要将数据同步到从数据库。

(2)、写入数据后,在向从数据库同步数据期间数据要被锁定,待同步完成后再释放锁,以免在新数据写入成功后,向数据库中查询到的是旧数据。

分布式一致性的特点

  1. 、由于需要同步数据,所以会存在延迟
  2. 、为了保证数据一致性会对资源暂时锁定,等待数据同步完毕后再释放
  3. 、请求数据同步失败的节点则会返回错误信息,一定不会返回旧数据,强一致性

A:可用性,指的是任何事务操作都可以得到响应结果,且不会出现响应超时或者错误。

如何实现可用性?

  1. 、在数据同步期间,不可以将数据锁定,允许读取数据
  2. 、即使数据还没有同步过来,如果有请求查询数据,则返回旧数据即可,但不能返回错误信息或者响应超时。

分布式可用性的特点

  1. 、所有请求都有响应,宁愿返回旧数据也不返回响应超时或者响应错误。

P: 分区容错性。在分布式系统中遇到任何网络分区故障,系统仍然能够正常对外提供服务。

如何实现分区容错性?

  1. 、尽量使用异步取代同步操作,例如使用异步方式将数据库从主数据库同步到从数据库,这样节点之间能有效的实现松耦合
  2. 、添加从数据库节点,一个节点挂了其他节点提供服务。

分布式分区容错性的特点

  1. 、分区容错性是分布式系统具备的基本能力。

CAP理论证明,在分布式系统中,要么满足CP,要么满足AP,无法满足CAP或者CA。在分布式系统中必须满足分区容错性。

AP:放弃强一致性,实现最终的一致性,很多互联网公司的选择。

CP:放弃高可用性,实现强一致性和分区容错性,2PC和3PC都采用的这种方案,可能导致的问题就是用户完成一个操作需要很长的时间,体验极差。

  1. 解释一下BASE理论

BASE理论是由于CAP中一致性和可用性不可兼得而衍生出来的一种新思想,BASE理论的核心思想是通过牺牲数据的强一致性来获得高可用性。

BASE理论特点

  1. 、基本可用:分布式系统出现故障,可以允许损失一分部功能的可用性,保证核心功能的可用性。
  2. 、软状态:允许系统中的数据存在中间状态,这个状态不影响系统的可用性,比如数据同步就会存在中间状态
  3. 、最终一致性:中间状态的数据在经过一段时间后,会达到最终一致性。

BASE理论并没有要求数据的强一致,而是允许数据在一段时间内是不一致的,但是数据最终会在某个时间点一致。在互联网产品中,大部分采用BASE理论来实现数据的一致,因为产品的可用性对于用户来说最重要。

  1. 解释一下Seata的组成

Seata是一款开源的分布式事务解决方案,它提供了TCC、AT、Saga、XA事务模式。AT模式是目前Seata最主推的一种分布式解决方案。

TC: 事务协调器,独立运行的中间件,需要独立部署运行,维护全局事务的运行状态,接收TM指令发起全局事务提交和回滚,负责和RM通信协调各分支事务的的提交或者回滚。

TM:事务管理器,需要嵌入应用程序工作,负责开启一个全局事务,并最终向TC发起全局事务的提交或者回滚指令。

RM: 也就是事务参与者,每个数据库实例。

  1. 解释一下Seata的AT模式的执行流程

举例子,比如现在有用户服务和积分服务,分别对应两个数据库,需求是和用户注册成功就加10个积分,在分布式环境下如何保证数据的一致性?

  1. 、事务管理器向事务协调器申请一个全局的XID(事务ID),XID存储到ThreadLocal中
  2. 、用户服务作为事务管理者(分支事务)注册到事务协调器中,并交由全局事务ID对应的事务协调器管辖
  3. 、用户服务执行新增逻辑,新增一个用户到数据表中,提交事务,同时生成undo_log
  4. 、积分服务作为事务参与者(分支事务)注册到事务协调器的中,并交由全局事务ID对应的事务协调器管辖
  5. 、积分服务执行新增逻辑,新增这个用户对应积分到数据表中,提交事务,同时生成undo_log
  6. 、事务管理器向事务协调器汇报资源的准备状态
  7. 、事务协调器汇总所有分支事务的准备状态,向事务管理器下达全部提交或者是回滚的指令
  8. 、如果是回滚那么就根据第一阶段生成的undo_log执行反向操作。

传统2PC和Seata的2PC的区别是什么?

  1. 、从架构层面来说,传统2PC中RM就是一个数据库实例,但是在Seata的2PC中,RM是以一个JAR包的形式嵌入到应用程序中的。
  2. 、传统2PC准备阶段不提交本地事务,而Seata准备阶段会提交事务,这样在第一阶段就不会锁住资源,而在第二阶段就减少了锁资源而消耗的时间。

  1. 为什么Seata的AT模式下需要使用代理数据源?

(1)、每个RM都使用DataSourceProxy连接数据库,其目的是使用ConnectionProxy,使用数据源和数据代理的目的就是在第一阶段将undo_log和业务数据放在一个本地事务提交,这样就保证只要有业务数据就一定会有undo_log,而undo_log需要在第二阶段回滚时使用。

(2)、第一阶段的undo_log中存放了数据修改前和修改后的值,为事务回滚做好了准备,所以第一阶段完成就已经将分支事务提交,也就释放了锁资源。

(3)、TM开启全局事务,将全局事务id放在事务上下文中,通过feign或者resttemplate调用将XID传入下游分支事务,每个分支事务将自己的BranchID与XID关联。

(4)、第二阶段的全局事务提交,TC会通知各个分支参与提交分支事务,在第一阶段就已经提交了分支事务,在这里参与者只需要删除undo_log即可,并且可以异步执行。

(5)、第二阶段全局事务回滚,TC会通知各个分支参与者回滚事务,通过XID和BranchID找到相应的回滚日志,生成反向SQL并执行,以完成分支事务回滚到之前的状态,如果回滚失败则会重试回滚操作。

  1. Seata的AT模式下的隔离级别是什么?

在数据库本地事务隔离级别为“读已提交”或以上的基础上,Seata的默认全局隔离级别为 “读未提交”,有可能会产生脏读。

如果必须使用全局的读已提交,目前Seata的方式是通过SELECT FOR UPDATE语句代理,因为SELECT FOR UPDATE 属于当前读,读取的是最新的记录,读取时保证其他并发事务不修改当前记录,会对读取的记录加锁。出于性能的考虑,Seata目前的方案并没有针对所有SELECT语句都进行代理,仅针对FOR UPDATE的SELECT语句。

  1. 解释一下分布式事务的解决方案的TCC

TCC是Try、Confirm、Cancel三个词语的缩写,TCC要求每个分支事务实现三个操作,预处理Try、确认Confirm、撤销Cancel。

(1)、Try:业务检查、资源预留。这个阶段仅仅是一个初步操作,需要和后续的Confirm配合才能完成一个真正的业务逻辑。

(2)、Confirm:业务确认,Try阶段所有分支事务执行完成后才会执行Confirm,采用TCC则认为Confirm阶段是一定不会出错的。即只要Try成功,Confirm就一定成功,若真的出错,则需要引入重试机制或者人工处理。

(3)、Cancel:撤销(回滚操作),在业务执行错误需要回滚的状态下执行分支事务的业务取消,预留资源释放,通常情况下采用TCC则认为Cancel阶段也是一定成功的。若真的出错,则需要引入重试机制或者人工处理。

如果所有分支事务的Try阶段都成功,则Confirm一定成功。如果Confirm/Cancel操作失败,则TM会进行重试。

如果分支事务的某一个Try成功,另一个Try失败,则在Confirm阶段执行Cancel撤销成功的那个分支事务,也就是回滚。

注意:TM事务管理器可以实现为独立的服务,也可以让全局事务发起方充当TM的角色,实现为独立的服务是为了成为公用组件,是为了考虑系统结构和软件复用。

TM在发起全局事务时生成全局事务记录,全局事务ID贯穿整个分布式事务的链条,用来记录事务上下文,追踪和记录状态。由于Confirm和Cancel失败都需要进行重试,所以必须实现幂等性,幂等性指的是同一个操作无论请求都少次,其结果都是相同的。

  1. 解释一下分布式解决方案的可靠消息最终一致性

可靠消息最终一致性指的是事务的发起方执行完本地事务后,向事务的参与者发送一条消息, 事务的参与者收到消息后执行本地事务,此方案强调的是只要消息发送给事务参与者最终事务就要达到一致性。利用的是消息中间件。

注意:只要消息发出了,事务的参与者就一定能接收消息并成功处理事务,实现最终一致性。

可靠消息最终一致性的问题

  1. 本地事务消息发送的原子性问题:事务发起方在本地事务执行成功后必须把消息发送出去,即实现本地事务和发送消息是原子性的,要么都成功、要么都失败。本地事务与消息发送的原子性问题是可靠消息时最终一致性方案的关键问题。

方案1(存在问题):假设先发送消息再进行数据库操作:无法保证消息发送和本地事务执行的原子性,因为可能消息发送成功,但是本地事务执行失败,消息没办法回退。

方案2(存在问题):先进行数据库操作再发送消息:貌似没问题,如果MQ发送失败,则会抛出异常,抛出异常则回滚事务。单是如果因为超时异常,数据库回滚,但是消息已经发送出去了。

  1. 、事务的参与方接收消息的可靠性:事务参与方一定要接收到消息,接收失败可重复接收消息。
  2. 、消息重复消费的问题:由于网络2的存在,若某一个消费节点超时但是消费成功,此时消息中间件会重复投递此消息,就导致了消息的重复的消费。
  1. 可靠消息最终一致性的解决方案有哪些?
  1. 、采用本地消息表
  2. 、采用RocketMQ事务消息方案。

  1. 如何保证的本地事务和消息发送的原子性(本地消息表)

 本地消息表 + 定时任务扫描发送:通过本地事务保证数据业务操作和消息的一致性,然后通过定时任务将消息发送到消息中间件,待确认消息发送给消费方后删除消息。消息会被记录在本地消息表中,可以是redis。

例如有一个需求:现有“用户服务”和“积分服务”,当用户注册成功之后,赠送积分。

这里就涉及两个不同数据库的操作,存在分布式事务问题,那么如何保证本地事务和消息发送的原子性呢?

——>执行完新增用户的操作之后,将 “增加积分消息”日志进行记录,存储到redis当中,然后启动独立的线程,通过定时任务来查询并发送消息到中间件,等待消费方的确认,消费方确认接收到消息之后,停止消息的发送,否则根据定时任务周期不断重试发送消息。

那么如何确认消费者已经接收到消息了呢?看下面

  1. 如何防止消息的重复发送呢? (本地消息表)

可以使用MQ的ack,即消息确认机制,消费者监听MQ,如果消费者接收到消息并且业务处理完成后向MQ发送ack,即确认消息,此时说明消费者正常消费消息完成,MQ将不再向消费者推送消息,否则事务发起方会不断的通过定时任务发送消息。

由于消息会重复投递,积分服务的 “增加积分”功能需要实现幂等性。

  1. RocketMQ如何保证本地事务和消息发送的原子性?

RocketMQ4.3 后实现了完整的事务消息,实际上其实是对消息表的一个封装,将本地消息表移动到了MQ内部,解决了生产端消息发送和本地事务执行的原子性问题。

具体流程如下所示:

注意:如果MQ发送方告诉MQ服务本地事务commit成功,则MQ服务就会将消息的状态改为”可消费”。如果MQ发送方告诉MQ服务rollback成功,则MQ服务就会将消息删除不进行投递。

  1. 什么是最大努力通知

重点在于:充值系统要把充值结果通知到账户系统,如果说因为网络等因素通知不到账户系统,则提供一个消息校对机制,账户系统可以主动的去查询充值结果。

最大努力通知的目标:发起通知方通过一定的机制尽自己最大的努力将业务处理结果通知到接收方。

具体包括

  1. 、有一定的消息重复通知的机制,因为接受通知方可能没有接收到通知,此时有一定的机制对消息重复通知
  2. 、消息校对:如果说最大努力通知也没有通知到接受方,或者说接收方接收消息后要再次进行消费,则可以主动的向通知方查询。

  1. 最大努力通知方案和可靠消息一致性通知方案的区别
  1. 、解决方案的思想不同

可靠消息一致性消息的可靠性关键因素是:发起方

最大努力通知消息的可靠性关键因素:接收方,因为接收方可以接收发送方的消息,也可以主动的查询消息。

(2)、两者的业务应用场景不同

可靠消息一致性关注的是交易过程的事务一致性,异步方式完成交易(交易过程

最大努力通知关注的是交易后的通知事务,即提交结果可靠的通知出去(交易后

  1. 、技术解决方向不同

可靠消息要解决的是消息从发出到接收的一致性,即消息发出并且被接受到。

最大努力通知无法保证消息从发出到接收的一致性,只提供消息接收的可靠性机制。可靠机制是最大努力的将消息发送出去,当消息无法被接收方接收时,接收方可以主动的查询消息(业务处理结果)

  1. 最大努力通知方案的解决方案

最大努力通知可以采用MQ的ACK机制就实现最大努力通知。

方案1:

  1. 、发起通知方将通知发送给MQ,使用普通消息机制发送
  2. 、接收通知方监听MQ
  3. 、接收通知方接收到消息,业务处理完成回应ACK
  4. 、接收通知方没有回应ACK,则MQ会重复通知

MQ按照间隔1min、5min、10min的方式逐步扩大同志间隔,比如使用的是RocketMQ,则在broker中可以进行配置。

  1. 、接收通知方可通过消息校对机制来校对的消息的一致性。

这种方案只适合内部应用的通知。

方案2:

也是利用MQ的ACK机制,但是与方案1不同的是增加一个“通知程序”向接收方发送通知

比如有时候咱们调用的是第三方的Api,不可能让第三方API去监听我们的MQ。

  1. 、发起通知方将通知发送给MQ服务:使用可靠消息一致方案
  2. 、通知程序监听MQ,接收MQ的消息。方案1中接收通知方直接监听MQ,方案2中有由通知程序监听MQ,通知程序如果没有ACK,则MQ会重复通知。
  3. 、通知程序通过互联网接口协议(比如http)调用接收通知方接口,完成通知。

通知程序调用接收通知方接口成功就表示通知成功,即消费MQ消息成功,MQ将不再向通知程序投递通知消息。

方案1和方案2的不同点

  1. 、方案1中接受通知方监听MQ,方案2中接收通知方监听通知程序接口,内部应用
  2. 、方案2中由通知程序监听MQ,此方案主要应用于外部应用之间的通知,支付包、微信等。

分布式事务解决方案实际应用

以互联网金融项目的实际业务场景举例。

P2P金融项目也叫作P2P信贷,P2P的意思就是个人对个人,P2P金融指的是个人与个人之间的小额借贷交易,借款者可以自行的发布借款信息,包括金额、利息、还款方式和时间,实现自助式借款。投资者可以根据借款人发布的信息、自行决定出借的金额,实现自助式借贷。采用“银行存管模式”来规避P2P平台挪用借投人资金的风险,通过银行开发的银行存管系统管理投资者的资金,每位P2P平台用户在银行的存管系统内都会有一个独立的账号。平台来管理交易,做到资金和交易分开,让P2P平台不能接触到资金,在一定程度上就避免了资金被挪用的风险。

有统一账号、统一认证、用户中心、标的检索、交易中心还款服务、支付服务、内容管理服务等8个服务构成,本人主要涉及的是用户中心、交易中心、还款服务、统一账号等服务的设计和开发,里面遇到了大量分布式事务的问题,根据我所掌握的分布式事务解决方案结合实际的业务解决了这些问题。

  1. 、注册账号

业务流程:

粗略流程:由于本平台采用的是用户、账号分离设计(好处是当用户的业务信息发生变更时,不会影响认证、授权等机制),因此需要保证用户信息和账号信息的一致性。也就是当用户注册成功的同时需要创建这个用户对应的账号。

具体流程:用户通过浏览器注册用户,用户中心生成用户信息,(用户名作为与账号统一关联的关联项),创建登录账号,统一账号服务需要创建账号信息,比如用户名、密码。返回给用户中心创建成功,用户中心在返回给用户注册成功。

遇到的问题:

由于是两个不同的服务,一个是用户中心服务,一个是统一账号服务,所以就存在分布式事务的问题。

  1. 、存管开户

业务背景:

根据要求,P2P业务必须让银行存管资金,用户的资金在银行存管系统的账户中,而不在P2P平台中,因此用户需要在银行存管系统中开户。

业务流程:

用户向用户中心提交开户资料,用户中心生成开户请求并重定向到银行存管系统开户页面。用户设置存管密码并确认开户后,银行存管立即返回 “请求已受理”。在某一时刻,银行存管系统处理完该开户请求后,调用回调地址通知处理结果,若通知失败,则按照一定的策略重试通知。同时,银行存管系统应提供开户结果查询的接口,供用户中心校对结果。

  1. 、满标审核

业务背景
在借款人募集够所有的资金以后,P2P运营管理员审批该标的,触发放款,并开启还款流程。

业务流程

管理员对某标的审核通过之后,交易中心修改标的状态为 “还款中”,同时要通知还款服务生成还款计划。

  1. Seata的AT模式的实际应用

针对问题(1)

采用Seata的AT模式解决 账号注册中“用户中心服务”和“统一账号服务”的分布式事务问题

追问:为什么采用Seata的AT模式?

针对注册业务,如果用户和账号信息不一致,则会导致严重的问题,因此一致性要求比较高,即当用户中心服务和统一账号服务任一方出现问题都需要回滚事务。根据这个业务规则,选用Seata的AT模式来实现分布式事务,Seata的AT模式也就是2PC,依赖于数据库的,所以具有回滚功能,主要流程如下。

  1. 、在用户中心这边加全局事务注解@GloabTransaction,用户中心即为事务管理器(TM)的角色存在。添加用户信息。
  2. 、统一账号服务添加账号信息,作为事务参与者存在
  3. 、其中某一方执行失败Seata都会根据undo_log生成反向SQL并完成回滚。

追问:为什么不采用可靠消息一致性方案或者是最大努力通知方案?

可靠消息一致性实现的是最终一致性,也就是可靠消息一致性要求只要消息发出,事务参与者收到消息就要将事务执行成功,不存在回滚的要求,所以不适用。

最大努力通知方案实现的也是消息的最终一致性,即使某一方事务执行失败也不会回滚事务,所以不适用。

  1. 最大努力通知方案的实际应用

针对问题(2)

采用MQ的ACK机制就实现最大努力通知解决此分布式事务问题。(跨系统的方案)

追问:为什么采用最大努力通知解决方案?

P2P平台的用户中心与银行存管系统之间属于跨系统交互,银行存管系统属于外部系统,用户中心无法干涉银行存管系统,所以用户中心只能在收到银行存管系统的业务处理结果通知后积极处理,开户后的使用情况完全由用户中心来控制。基于上面的业务规则,只能使用最大努力通知方案的跨系统方案来解决此分布式事务问题。主要流程如下

  1. 、银行存款系统内部使用MQ,银行存管系统处理完业务后将处理结果发送给MQ,
  2. 、由银行存管的通知程序发送通知,用户通知方并可以得到通知
  3. 、如果没有发送通知,则用户中心中心可以调用银行存管提供的业务结果查询接口查询。定期校对。

追问:为什么不采用Seata的AT模式、TCC或者是可靠消息一致性方案?

采用Seata的AT模式:需要侵入银行存管的数据库,由于它是外部系统,不适用。

采用TCC:侵入性更强

采用MQ可靠消息一致性:首先银行系统不可能让P2P平台去监听它的MQ,其次P2P平台的MQ也不可能让银行系统监听,所以不合适。

  1. 可靠消息最终一致性方案的实际应用

问题(3)

采用基于MQ的ACK机制的可靠消息最终一致性方案来解决此分布式事务问题。

追问:为什么采用可靠消息最终一致性方案呢?

此业务对一致性的要求不是很高,但是对快速响应的要求比较高,也就是时间线不能太长。因为还款服务中涉及到还款计划的计算。所以综合下来采用基于MQ的ACK机制的可靠消息最终一致性方案最为合适。

追问:为什么不采用Seata的AT模式的解决方案或者是TCC解决方案呢?

采用Seata的AT模式:Seata的AT模式会锁住资源,导致线程积压,时间长

采用TCC:也会锁住资源,时间长

架构设计知识篇

  1. 微服务架构和SOA架构的区别

SOA架构的服务之间是通过相互依赖最终提供一系列的功能,微服务是SOA架构的升华,微服务的一个重点是“业务需要彻底的组件化和服务化”,原有的单个业务系统会拆分为多个可以独立开发、设计、运行的小应用,而这些小应用之间通过服务完成交互和继承。微服务不再强调传统SOA架构里面比较重的ESB企业服务总线,同时SOA的思想进入到单个业务系统内部实现真正的组件化。(SOA架构的ESB和微服务的网关类似)

  1. 微服务架构遵循的原则

微服务带来的收益

  1. 、单个的微服务,可以选择一门自己擅长的编程语言去开发,扩展性强
  2. 、对于整个应用代码不再耦合,不会出现大量冲突
  3. 、微服务可以重用、应用发布时间可控性更强
  4. 、微服务可以实现熔断降级,让错误在服务中降级,而不影响整个系统或者其他服务

微服务设计的原则

  1. 、职责独立
  2. 、服务依赖链路不宜过长,建议不超过3个
  3. 、使用熔断器实现快速的故障容错和线程隔离,例如Hystrix、Sentinel
  4. 、通过网关代理微服务请求、网关是微服务架构对外暴露的唯一入口
  5. 、确保微服务API变更后能够向后兼容

  1. 什么是领域驱动设计(DDD)

DDD是一种软件架构设计方法,它并不定义软件开发过程(Devops)

DDD利用面向对象的特性,以业务为核心驱动,而不是传统的数据库驱动开发

领域:领域是对功能需求的划分;大的领域下面还有许多下的子领域

传统的软件开发总是以设计数据表开始的,而DDD前期考虑的是业务,而不是数据表

  1. 电商工程设计

工程入口及用户鉴权微服务

网关是微服务架构的唯一入口

(1)、鉴权微服务:登录、注册

(2)、网关微服务:路由配置、限流配置、过滤器

注意:网关微服务和鉴权微服务是电商工程的门面。

电商功能微服务

账户、商品、订单、物流(都在网关的背后)

登录授权解决方案

  1. 基于服务器的身份认证的缺点有哪些?
  1. 、最为传统的做法:客户端存储Cookie(一般是SessionId),服务器存储Session
  2. 、Session是每次用户认证通过后,服务器需要创建一条记录保存用户信息,通常是在内存中,随着认证通过的用户越来越多,服务器在这里的开销就会越来越大。
  3. 、在不同域名之间进行切换,请求可能会被禁止,即跨域问题

  1. 基于Jwt的身份认证的优点
  1. 、Jwt与Session的差异相同点是,它们都是存储用户信息,然而Session是在服务器端的,而Jwt是存在客户端的。通过算法解析用户信息
  2. 、Jwt方式将用户状态分散到了客户端中,可以明显减轻服务端的内存压力。

  1. 基于Jwt的身份认证和基于服务器认证的优缺点

解析方法:Jwt使用算法解析用户信息,Session需要额外的数据映射实现匹配

管理方法:Jwt是存储在客户端的,只有过期时间的限制,而Session是存储在服务端的,可控性更强。

跨平台:Jwt只是一段字符串,可以任意传播,Session需要有统一的解析平台

时效性:Jwt一旦生成,独立存在,很难做特殊控制,Session时效性完全由服务端说了算

Redis知识篇

Redis基础知识

  1. Redis为什么能支撑10W+QPS

Redis特点

  1. 内存型数据库,速度快,也支持数据的持久化
  2. Redis支持多种数据结构
  3. Redis支持数据的备份和集群(分片机制)、读写分离、主从模式、哨兵机制
  4. 支持事务,Redis优势
  5. 性能极高,Redis能读取的速度是110000次/s,写的速度是81000次/s
  6. 丰富的数据类型
  7. 原子操作、支持事务

Redis原理

  1. 纯内存数据库,读取速度快
  2. Redis使用的是非阻塞IO,IO多路复用,减少了线程切换时上下文的切换和竞争
  3. Redis采用单线程模型,减少线程上下文的切换和竞争
  4. 存储结构很多,不同的数据结构对数据存储进行了优化加快读取速度
  5. 采用事件分离器,效率高

  1. 解释一下Redis的IO多路复用原理

Redis采用IO多路复用技术,实现多个连接共用一个线程,保证高并发下系统的吞吐量

所谓IO多路复用技术即多个网络连接对应一个线程,采用IO多路复用技术可以让单线程高效的处理连接请求。

  1. 解释一下为什么Jedis需要使用数据源

Jedis默认是直接操作Redis的,当在并发量非常大的时候,那么Jedis操作Redis的连接数很有可能就会异常,因此为了提高操作效率,引入连接池。

Jedis池化技术在创建时初始化一些连接资源到连接池中,使用Jedis连接资源时不需要创建,而是从连接池中获取一个资源进行redis操作,使用完毕后也不需要销毁,而是将该资源归还给连接池。供其他请求使用。

  1. 解释一下什么是缓存雪崩?

缓存雪崩指的是同一时间缓存大面积的失效,然后后面所有的请求都落到了数据库上,造成数据库短时间内承受大量的请求而崩溃。

解决方案:

  1. 缓存数据的过期时间随机设置,防止同一时间大量数据过期的现象产生。
  2. 缓存预热(针对应用刚起起来数据还没有加载到redis而导致的雪崩),可以写一个接口把热点数据放在缓存。
  3. 互斥锁,排队,一个个来
  4. 采用集群部署,一个挂了另一个顶上

  1. 解释一下什么是缓存穿透

缓存穿透是指的缓存和数据库中都没有数据,导致所有的请求都落在数据库上,造成短时间内大量请求落在数据库而崩掉。(来自黑客攻击)

解决方案:

  1. 、在业务层增加校验,如果当前请求的这个数据本身就不存在,就不让请求往下走
  2. 、缓存无效的key,如果缓存和数据库中查询不到某个key的数据,就写一个到redis中的去并设置过期时间,这个主要是防止请求Key变化不频繁的现象。
  3. 、采用布隆过滤器:把所有可能存在的请求的值都放在布隆过滤器中,用户请求过来时先判断用户的请求是否在于海量数据中,我们需要的就是判断key是否合法,不存在就直接返回错误。(存储在bitmap中)

  1. 解释一下什么是缓存击穿?

缓存击穿指的是缓存中没有但是数据库中有的数据,这时由于并发用户特别多,同时缓存没有读取到数据,又同时去数据库读取数据,引起数据库压力瞬间增大,造成压力过大。

缓存雪崩和缓存击穿的区别: 缓存击穿是并发访问某一数据,缓存雪崩是所有不同数据失效。

解决方案:

(1)、设置热点数据永不过期(不推荐)

(2)、加互斥锁 setNx

  1. 说一下Redis中有哪些集群方案

主从复制模式

哨兵模式(主从模式升级版)

Cluster模式

主从复制模式

指的是将redis分为主从节点,比如可以从主节点读写数据,从节点只读数据,主数据库写入的数据会实时自动同步给从数据库。

具体工作机制

(1)、slave启动后,向master发送SYNC命令,master接收到SYNC命令后通过bgsave保存快照(即上文所介绍的RDB持久化),并使用缓冲区记录保存快照这段时间内执行的写命令

(2)、master将保存的快照文件发送给slave,并继续记录执行的写命令

(3)、slave接收到快照文件后,加载快照文件,载入数据

(4)、master快照发送完后开始向slave发送缓冲区的写命令,slave接收命令并执行,完成复制初始化

(5)、此后master每次执行一个写命令都会同步发送给slave,保持master与slave之间数据的一致性

哨兵模式

哨兵模式基于主从复制模式的,只是引入了哨兵来监控和自动处理故障。

哨兵的功能

  1. 、监测master、slave是否正常运行
  2. 、当master出故障时,自动的将一个slave换为master
  3. 、哨兵之间自动监控

Cluster模式

哨兵模式解决了主从复制模式故障不可自动转移、达不到高可用的问题,但还是存在难以在线扩容的问题的,Redis受限于单机配置的问题,Cluster模式实现了redis的分布式存储,即每台节点存储不同的内容。来在线解决扩容的问题。

  1. 说一下分片集群解决了什么问题?

对于大数据量的存储,尤其是持久化存储,通过分片,可以将数据存储到不同的节点上,通过降低单服务数据量级来提升数据处理的效能,从而达到拥有数据处理横向扩展的能力。

  1. 解释一下Redis的AOF和RDB持久化方式

内库数据 -> 硬盘 : 重用数据 -> 为了防止系统故障而将数据备份到一个远程位置

RDB持久化

Redis可以通过快照来获得存储在内存里面的数据在某个时间节点上的副本。

  1. 哪些情况下会对数据进行RDB快照
  1. 根据自定义的配置规则进行快照,例如save 900 1 表示的是如果900秒内有一个键发生改变,则进行RDB快照。
  2. 执行SAVE命令进行快照,但是SAVE命令会阻塞所有来自客户端的请求,所以如果数据量大的情况下,可能会导致响应慢甚至长时间无响应的情况发生。不推荐使用。
  3. 执行BGSAVE命令,BGSAVE命令不会导致来自客户端的请求阻塞,它是以异步的方式去执行快照操作的,在进行快照的同时Redis服务器还可以响应来自客户端的请求,执行BGSAVE命令后会立即返回OK,可以通过LASTSAVE获取最近一次成功进行的快照。
  4. 执行FLUSHALL命令,FLUSHALL命令会清除数据库中的所有数据,需要注意的是,不论清空数据库的过程是否触发了自动快照的条件,只要自动快照条件不为空,Redis都会执行一次快照操作。
  5. 执行复制时,当设置了主从模式时,Redis会在复制初始化时进行自动快照,即使没有定义快照条件,也会进行自动快照。
  1. RDB快照原理

Redis使用fork函数复制一份当前进程的副本(子进程),父进程继续接收来自客户端的请求,子进程开始将内存中的数据写入到硬盘,当子进程写入所有数据后会使用该临时文件替换掉旧的RDB文件,至此一次快照操作完成。

  1. 为什么RDB适合用来做数据备份
  1. Redis在进行快照的过程中不会修改RDB文件,只有快照结束后才会将旧的RDB文件替换为新的RDB文件,也就是说任何时候RDB文件都是完整的,这使得我们可以通过定时备份RDB文件来实现Redis的数据备份。
  2. RDB文件都是经过压缩的二进制文件,占用的空间非常小,有利于网络传输

  1. RDB快照的缺点

使用RDB快照的方式,一旦Redis异常退出,就会丢失最后一次快照以后更改的所有数据。这就需要开发者集合实际场景,通过调整配置规则来将可能发生丢失的数据控制在能接受的范围。

AOF持久化

  1. AOF介绍

一般需要打开AOF持久化来降低进程终止导致的数据丢失。AOF可以将Redis执行的每一条命令追加到硬盘文件中,这一过程显然会降低Redis的性能,但是大部分情况下这个是可以接受的。另外使用读写速度比较快的硬盘可以提高AOF的性能。

  1. 如何开启AOF

默认情况下,redis没有开启AOF持久化,AOF的实时性更好,可以通过appendonly yes开启AOP持久化机制。

开启AOF持久化之后每执行一条会更改Redis中数据的命令,Redis就会将该命令写入硬盘中的AOF文件,保存位置和RDB一样,都是通过dir设置的。默认的文件名为appendonly.aof,可以通过appendfilename参数修改。

  1. 如何同步硬盘数据

每次执行更改数据库内容的操作时,AOF都会将命令记录在AOF文件中,但是事实上,由于操作系统的缓存机制,数据并没有真正的写入到硬盘中,而是存在了操作系统的硬盘缓存中,操作系统每隔30秒回执行一次同步,将缓冲中的数据同步到硬盘中。在这30的过程中,如果Redis服务中途异常退出,就会导致前30秒到现在的数据丢失的情况,因此Redis支持自定义AOF持久化的策略,修改appendfsync即可,如下所示。

  1. 、always : 每次数据发生修改都会持久化到AOF
  2. 、everysec:每秒钟同步一次,显示的将多个命令同步到磁盘
  3. 、no :让操作系统决定何时进行同步

为了兼顾数据和写⼊性能,⽤户可以考虑 appendfsync everysec 选项 ,让 Redis 每秒同步⼀次AOF ⽂件,Redis 性能⼏乎没受到任何影响。⽽且这样即使出现系统崩溃,⽤户最多只会丢失⼀秒之内产⽣的数据。当硬盘忙于执⾏写⼊操作的时候,Redis 还会优雅的放慢⾃⼰的速度以便适应硬盘的最⼤写⼊速度。

  1. 如何优化AOF文件

打开appendonly.aof文件可以看到,里面其实是有冗余的数据的,那么随着我们执行的命令增多,appendonly.aof文件会越来越大,而这些冗余数据是我们不需要的,这时候就可以采取重写的方式优化这个文件,如果不借助Redis自身的配置,那么也可以使用命令BGREWRITEAOFD手动重写AOF文件。

如果是自动重写,则默认的配置是 auto-aof-rewrite-percentage 100,表示的是目前的AOF文件超过上一次AOF文件的百分之多少时会发生重写。

AOF和RDB搭配使用

一般情况下我们都采取AOF和RDB同时开启的方式,这样既可以保证数据的安全性,也可以保证备份工作的顺利进行,此时重启Redis后Redis会采用AOF的方式来进行数据恢复,因为AOF方式的持久化机制丢失数据的概率小。

  1. 解释一下Redis中的事务

Redis可以通过MULTI、EXEC、DISCARD和WATCH来实现事务的功能,使用了MULTI命令之后可以输入多个命令,Redis不会立即执行命令,而是将他们放入队列,当调用了EXEC命令将执行所有命令。

Redis是不支持rollback的,所以不满足原子性的,也不满足持久性

总的可以理解为Redis事务提供了一种将多个命令请求打包的功能,然后在按照顺序执行打包所有的命令,并且不会被中途打断。

  1. 解释一下Redis的纵向扩展和横向扩展

纵向扩展:升级单个Redis实例的配置,包括增加内存容量、增加磁盘容量、使用更高配置的CPU。考虑硬件容量和成本,且没办法解决主线程阻塞的问题,除非不持久化。

横向扩展:增加Redis实例的个数,相同配置的个数。

  1. 解释一下Redis过期数据删除策略

惰性删除:只有在取出key的时候才对数据进行过期检查,对CPU最友好,但是可能会导致大量的过期key没有被删除。

定期删除:每隔一段时间抽取一批key进行检查并删除,Redis 底层会通过限

制删除操作执⾏的时⻓和频率来减少删除操作对CPU时间的影响,定期删除对内存更加友好,惰性删除对CPU更加友好。两者各有千秋,所以Redis 采⽤的是 定期

删除+惰性/懒汉式删除。

  1. 解释一下Redis的内存淘汰策略
  1. 、lru: 最近最少使用
  2. 、random:选取随机数据淘汰
  3. 、ttl: 挑选将要过期的数据进行淘汰

4.0版本后增加了两种

  1. 、valatile - lfu:从已经设置了过期时间的数据中挑选最不经常使用的数据进行淘汰
  2. 、allkeys-lfu: 当内存不足以容纳新写入的数据时,在键空间中,移除掉最不经常使用数据,保证redis里面存储的数据都是热点数据。

  1. 解释一下Redis的单线程模型

Redis是基于Reactor模式设计的一套高效的事件处理程序,这套事件处理模型对应的是redis中的文件事件处理器,由于文件事件处理器是单线程的。所以说redis是单线程的。

既然是单线程,那么redis是如何监听大量的连接的?

Redis通过IO多路复用原理来监听大量的客户端连接,它会将感兴趣的事件以及类型注册到内核中监听事件是否发生。

IO多路复用技术让redis不需要创建多余的线程来监听客户端的连接,降低了资源的消耗。

  1. 解释一下Redis为什么不使用多线程?

Redis4.0以后就开始支持多线程了。

但是多线程主要是用来解决大键值对的删除操作,这些操作就会使用其他的线程来进行操作,就不会造成阻塞。

为什么不使用?

  1. 、单线程维护简单
  2. 、Redis的性能不在CPU,而是内存和网络
  3. 、多线程会有死锁问题存在。甚至影响性能

  1. Redis如何实现自增自减?

命令方式:

自增:INCR key

自减:DECR key

Java的方式

自增:increment

自减:decrement

  1. 阻塞式IO和非阻塞式IO的区别

阻塞式IO

阻塞式IO指的是一旦输入/输出工作没有完成,程序就处于阻塞状态,直到输入输出工作的完成。

非阻塞式IO

非阻塞式IO也并非完全非阻塞,通常都是通过设置超时来读取数据的,未超时之前,程序阻塞在读取函数上;超时后,结束本次的读取,将已经读取到的数据返回。通过不断循环的读取,最终就能读取到完整的数据。

  1. Redis和MySQL之间如何保证数据的一致性

双写一致性策略

  1. 先更新缓存、再更新数据库

缺点:如果更新缓存成功,但是数据库更新失败,会造成缓存脏数据

  1. 先更新数据库、再更新缓存

缺点:高并发场景下,假设有线程A和B,线程A先更新数据库,还没来得及更新缓存,线程B就更新数据库并更新缓存,然后线程A才更新缓存,这就造成了线程B的更新丢失。

  1. 先删除缓存、再更新数据库

缺点:高并发场景下,线程A先删除缓存、准备更新数据库,这时线程B读取数据,发现缓存已经被删除了,所以到数据库读取数据,读取完毕之后再把数据写入缓存,这时候线程A更新数据库,导致缓存和数据库数据不一致。

解决方案:线程A更新完数据库之后,再删除一次缓存,也叫延迟双删。

  1. 先更新数据库、再删除缓存。

缺点:线程A读取数据库,然后准备准备写redis,这时候线程B更新数据库,并删除缓存,然后线程A才把数据写进去,这就导致了旧数据还存在于缓存。

解决方案:线程A再删除一次缓存、延迟双删

  1. 聊聊Redis的管道技术

客户端和Redis使用TCP连接,无论客户端向Redis发送命令还是Redis向客户端返回执行结果,它们都需要经过网络传输,这两个部分的总耗时称为往返延时。

Redis的底层通信协议对管道提供了支持,通过管道可以一次性发送多条命令并在执行完之后一次性将结果返回,当一组命令中的每条命令都不依赖于之前命令的执行结果时就可以将这组命令通过管道一起发出。管道通过减少客户端与Redis的通信次数来实现降低往返时延的目的。

Spring知识篇

  1. Spring中@Controller和@Restcontroller的区别

@Controller在不添加@ResponseBody的情况下返回的是一个视图,适用于SpringMVC,无法用于前后端分离的情况。
@RestController = @Controller + @ResponseBody返回的是JSON/XML格式的数据,属于RestfulWeb服务,这也是前后端分离最常用的。

@ResponseBody的作用是将Controller的方法返回的对象通过适当的转换器转换为指定的格式之后,写入HTTP响应的body中,通常用来封装JSON或者XML数据,返回JSON数据的情况比较多。

  1. 谈谈对SpringIOC的理解是什么样的?

IOC(控制反转)是一种设计思想,就是将原本在程序中手动创建对象的控制权,交给Spring框架来管理,IOC容器是Spring 用来实现IOC的载体,IOC实际上就是一个Map,Map中存放的各种对象。

IOC的初始化过程

XML  ->  Resource ->  BeanDefinition -> BeanFactory

  1. 谈谈对SpringAOP的理解是怎么样的?

AOP能够将那些与业务无关、却为业务模块共同调用的逻辑或者责任封装起来(例如日志处理、事务处理、权限控制等),便于减少系统的重复代码,降低模块之间的耦合度,并有利于未来的拓展性和可维护性。

SpringAop就是基于动态代理的,如果要代理的对象,实现了某个接口,那么SpringAOP会使用JDK代理,去创建代理对象,而对于没有实现接口的对象,就无法使用JDK Proxy去进行代理了,这时候SpringAOP会使用Cgliba生成一个被代理对象的子类作为代理。使用AOP之后我们可以把一些通用的功能抽象出来,在需要用到的地方直接使用即可,这样就大大简化了代码量,我们需要增加新功能时也方便,提高了系统的扩展性。日志功能、事务管理等等场景都用到了AOP。

  1. Spring管理事务的方式有几种?
  1. 、编程式事务,在代码中硬编码
  2. 、声明式事务,通过注解或者是xml方式

事务隔离级别有哪些?

(1)、DEFAULT: 使用后端数据库默认隔离级别

(2)、读未提交

(3)、读已提交

(4)、可重复读

(5)、串行化

Spring事务的传播行为有哪些?

如何理解@Transcation注解?

既可以作用在方法上,也可以作用在类上,比如@Transcation(rollbackFor = Exception.class)表示的就是当遇到运行时异常和非运行时异常时都要进行回滚操作。如果是@Transcation不指定rollbackFor,那么只有在遇到RuntimeException时才回滚,非运行时异常不回滚。

  1. Spring中使用了哪些设计模式?
  1. 、工厂设计模式:比BeanFactory、ApplicationContext等创建bean对象
  2. 、代理设计模式: 比如SpringAop就是使用了代理设计模式
  3. 、单例设计模式:比如Spring中的bean
  4. 、包装器设计模式:比如Spring连接数据库,动态切换数据源
  5. 、观察者设计模式:Spring的事件驱动模型就是使用的观察者设计模式
  6. 、适配器设计模式:Spring中的增强或者通知(Advice)使用到了适配器设计模式

  1. Spring Bean的创建过程

在spring中,万物都是bean对象,每一个对象都可以封装成BeanDefinition,然后去生成bean对象。

第一步,spring要找到哪些bean需要实例化:

    第一种是xml的方式,如果需要实例化bean就在xml中配置bean标签,找到所有需要创建的bean

    第二种注解方式,扫描所有添加了spring注解的bean,把所有的bean封装成一个BeanDefinition放入一个list.

第二步,循环list,通过BeanDefinition中的类全名称,通过反射进行实例化,属性注入,如果还有一个初始化的动作,也可以在属性注入后做,比如:init-method方法,比如实现了InitializingBean这个接口,然后在初始化的时候自动调用afterPropertiesSet该方法,我们可以在这个里面对bean做其他的操作,如果bean需要被代理,则通过后置通知,去生成代理的bean,如果bean实现了接口就使用jdk代理,如果没有实现就使用cglib,如果配置的优先级,则优先使用cglib.

第三步,完成后就将bean放入到spring的一级容器中。

MyBatis知识篇

  1. #{} 和 &{} 区别是什么?

${} 是Properties文件中的变量占位符,可以用于标签属性值和sql内部,属于静态文本替换

#{} 是sql的参数占位符,MyBatis会将 sql中的 #{}替换为?号,在sql执行之前会使用PreparedStatement的参数设置方法,按序给sql的?号占位符设置参数值。

  1. Mybatis中有哪些标签

<select> 、<insert>、<update>、<delete>、<selectKey>、<resultMap>、<sql> 、<include>等

  1. 最佳实践中,通常一个Xml映射文件,都会写一个Dao接口与之对应,请问这个DAO接口的工作原理是什么?Dao接口里面的方法能重载吗?

Dao接口也就是常说的mapper接口,接口的全限名就是xml里面的namespace的值,接口的方法名,就是映射文件中的MappedStatement的id值,接口方法内的参数,就是传递给SQL的参数值,Mapper接口是没有实现类的,当调用接口方法时,通过全限定名 + 方法名拼接字符串作为key,可以唯一定位一个MappedStatement,例如StudentDao.findStudentById,可以唯一找到namespace为StudentDao下面id =findStudentById 的MappedStatement,在MyBatis中都会被解析为一个MappedStatement对象。
Dao接口里面的方法是不能被重载的,因为是全限名 + 方法名的保存和寻找策略

Dao接口的工作原理是JDK动态代理,MyBatis运行时会使用JDK动态代理为Dao接口生成proxy对象,代理对象Proxy会拦截接口方法,转而执行MappedStatement所代表的sql,然后将sql执行结果返回。

  1. Mybatis是如何把进行分页的?分页的原理是什么?

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

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

  1. Mybatis物理分页和逻辑分页的区别是什么?

逻辑分页

逻辑分页依赖的是程序员编写的代码。数据库返回的不是分页结果,而是全部数据,然后再由程序员通过代码获取分页数据,常用的操作是一次性从数据库中查询出全部数据并存储到List集合中,因为List集合有序,再根据索引获取指定范围的数据。

物理分页

物理分页依赖的是某一物理实体,这个物理实体就是数据库,比如MySQL数据库提供了limit关键字,程序员只需要编写带有limit关键字的SQL语句,数据库返回的就是分页结果

  1. Mybatis的动态SQL是怎么做的?都有哪些动态SQL?执行原理说一下

MyBatis动态sql可以让我们在Xml映射文件中,以标签的形式编写动态SQL,完成逻辑判断和拼接SQL的功能,Mybatis提供了9种动态SQL标签

Trim 、where、foreach、choose、when、otherwith、bind、set等

原理为使用OGNL从SQL参数对象中计算表达式的值,根据表达式的值动态拼接SQL,以此来完成动态SQL的功能。

  1. Mybatis是如何将sql执行结果封装为目标对象并返回的?都有哪些形式?
  1. 、第一种是使用<resultMap> 标签,逐一定义列名和对象属性名之间的映射关系
  2. 、第二种是使用sql列的别名功能,将列别名书写为对象属性名,忽略大小写的

有了列名和属性名的映射关系后,MyBatis通过反射创建对象,同时使用反射给对象的属性逐一赋值并返回,那些找不到映射关系的属性是没办法完成赋值的。

SpringBoot知识篇

  1. 为什么使用SpringBoot?

SpringBoot能够实现快速开发、快速整合、配置简化、内嵌服务容器等

  1. SpringBoot的核心注解有哪些?

启动类上注解是@SpringBootApplication,也是SpringBoot的核心注解,包含了以下三个注解

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

@EnableAutoConfiguration:打开自动配置的功能

@ComponentScan:Spring组件扫描注解

  1. 说一下SpringBoot的约定大于配置体现在哪里?

Spring Boot Starter、SpringBoot jpa都是 “约定大于配置”的一种体现,都是通过约定大于配置的设计思路来设计的,SprinBootStarter在启动过程中会根据约定的信息对资源进行初始化。

框架提供的默认值会让我们的项目开发起来更有效率,如果默认值满足不了我们的需求,我们可以使用Properties配置文件和YAML配置文件来重写默认值来满足我们的需求,所以约定大于配置。但是这并不是一种新的思想,在JDK5.0发布时,采用元数据、引入注解的概念,就代表了简化配置的开始,这就是初期的一种“约定优于配置”的体现,所以 “约定优于配置”这一设计理念,从Spring的注解版本就已经开始了,引入注解就是为了减少一些默认配置,引入注解也就代表着简化配置的开始,官方说基于Spring的基础就是这个事实。

  1. SpringBoot在启动时都做了什么事情?
  1. 、SpringBoot在启动时会去依赖的Starter包中寻找resources/META-INF/spring.factories文件,然后根据配置文件中的Jar包去扫描项目所依赖的Jar包
  2. 、根据Spring.factories配置加载AutoConfigure类
  3. 、根据@Conditional注解的条件,进行自动配置并将Bean注入Spring Context

总结下来就是启动的时候,按照约定去读取Spring Boot Starter的配置信息,再根据配置信息对资源进行初始化并注入到Spring容器中,这样SpringBoot启动完毕后就已经准备好一切资源,使用的过程中直接注入Bean资源即可。

其他面试题:Spring Boot 面试,一个问题就干趴下了! - 纯洁的微笑 - 博客园

系统设计篇

  1. 集群服务器定时任务重复执行的解决方案

单点执行、故障转移、服务状态

  1. 、只在一台机器上运行定时任务并部署

缺点:单点故障其问题,如果这个点挂了那么就不存在定时任务了

  1. 、在定时任务上代码上限制IP,仅某个IP的服务器能够运行定时任务

缺点:单点故障问题,这台服务器发生故障就没办法了

  1. 、在数据库加定时任务表,每次执行时都去查询是否有其他服务器在执行定时任务,如果没有则先更新定时任务状态,在执行。UPDATE 表名 SET EXECUTE = 1 WHERE EXCUTE = 0 AND task_name = 定时任务名称。

缺点:数据库负担增大

  1. 、通过redis的过期时间和分布式锁来实现任务调度

优点:利用redis的自动过期机制实现了转移故障机器的问题,而且redis访问速度快

缺点:没有事务管理机制,访问redis时一定会出现高并发的情况。

Redis 分布式锁的最基本原理

Redis 有一个SETNX命令,该命令的功能如下,SETNX key value,将key的值设置为value,当且仅当 key不存在时,若给定的key已经存在,则SETNX不作任何操作,SETNX是 【SET if Not Exists】(如果不存在则SET的缩写),这样的话tomcat1、tomcat2、tomacatN就可以以谁最新执行了SETNX命令来作为有没有抢到锁的依据了,这就是能够使用redis做分布式锁的最基本原理。

参考文章:https://blog.csdn.net/nrsc272420199/article/details/106441612

  1. 秒杀场景Redisson实现分布式锁的知识

1. 悲观锁解决商品超卖问题

synchronized关键字

2. 乐观锁的方式解决超卖问题

根据版本号判断是否还能修改库存,利用了MySQL的排他锁,将并发交给MySQL数据库去处理

1、首先查询商品库存剩余量

2、扣减库存并且更改版本号,条件是 商品id = 传入的id,版本 = 上一步查询商品信息得到的版本(0)

3、新增订单

比如现在有1000个线程在1秒钟内涌入,这是一个并发操作,由于在服务层没有加任何的锁,所以会有很多的线程查询到商品剩余量并且开启事务即将要执行 update 操作(扣减库存),

由于MySQL的默认隔离级别是可重复读,而几乎所有的线程都是同一时刻开启事务,所以开启事务时的视图读取到的数据就是version  = 0的记录,

即使前面的线程事务已经提交,后面阻塞的线程读取到的数据也都是开启事务时读取到的数据(可重复读隔离级别下视图创建后中途是不会改变的),除非发生幻读(这种概率几乎没有),

只有等前面的线程提交事务释放锁资源后,后面的线程的事务才能提交(更改商品库存值),直到商品卖完了。

这就是乐观锁解决商品超卖问题的基本原理。

高并发系统的三大利器:缓存、降级、限流

接口限流

限流:对某一时间窗口内的请求数进行限制,保持系统的可用性和稳定性,防止因流量暴增而导致的系统运行缓慢或者宕机

在面临高并发的抢购请求时,如果我们不对接口进行限流,就会有大量的抢购请求调用下单接口,过多的请求打到数据库上会对系统造成一定的影响。

限流的目的是通过对并发访问的/请求进行限速,或者是对一个时间窗口内的请求进行限速的来保护系统,一旦达到限制速率则可以拒绝服务、排队或者等待

限流算法

漏斗算法、令牌桶算法(短时间内可以拿到大量令牌,而且消耗不是很大)

Google的开源项目Guava的RateLimiter就实现了令牌桶控制算法

令牌桶 + 乐观锁实现限流和超卖

隐藏秒杀接口  (单用户的防刷措施)

1. 我们在一定的时间内执行秒杀处理,不能在任意时间段内都接受秒杀,如何加入时间验证 (限时抢购)

2. 如何防止通过脚本进行抢购 (接口隐藏)

3. 秒杀开始之后如何限制单个用户的请求频率,即单个时间内限制访问次数 (单用户频率限制)

限时抢购

使用Redis记录秒杀商品的过期时间,对过期的请求拒绝

键(key) : 比如kill + 商品id

秒杀接口地址的隐藏

每次点击秒杀按钮,才会生成秒杀地址,在这之前是不知道秒杀地址的,因为地址不是写死的,是从服务端获取,动态拼接而成的地址,安全校验电还是要放在服务端,禁止掉这些恶意服务

思路如下

1.用户点击秒杀按钮,在进行真正的秒杀之前,先去请求一个服务端地址,这个请求用于生成动态的秒杀地址,传参为用户id和商品id,在服务端拼接用户id和商品id,作为hashKey,然后生成MD5值,

最终存入redis并设置过期时间,最后把生成的MD5值返回给前端。

2.前端拿到服务端生成的MD5之后,在用这个值拼接在URL上的作为参数,去请求后端的秒杀接口

3.后端接收到这个秒杀请求后,取出缓存中的md5值与请求参数中的对比,如果相同则执行下面的秒杀逻辑,如果不相同则直接视为非法请求

该操作可以防止恶意用户登录之后,获取到token的情况下,通过不断调用秒杀地址接口,来达到刷单的恶意请求

但是这种情况仍然不能解决利用按键精灵或者机器人频繁点击的操作,为了降低点击按钮的次数,以及高并发下,防止多个用户在同一时间内并发出大量请求,加入数学公式图形验证码等措施。

注意:

1.在获取MD5之前必须先判断一下秒杀活动是否开始,否则活动未开始也照样可以请求秒杀,或者是在请求秒杀接口前判断活动是否开始。

2.加验证码是为了防止连点器这种操作,而加随机秒杀地址是为了避免不通过页面点击而直接刷接口(只有通过点击才能获取地址)

synchronized锁在Spring事务管理下,为啥还是线程不安全?

在Spring的事务管理下,假设现在有一个线程执行但是还没有提交事务,然后另外一个线程开始读取数据并执行,由于第一个线程还没有提交,导致第二个线程读取到的不是最新的数据,所以导致线程不安全问题。

总结就是Spring事务的锁的范围比synchronized大很多

解决方案:

(1)、不加@Transaction注解 ,不推荐

(2)、更改事务隔离级别为 读未提交,不推荐

(3)、读取数据时使用当前读而不是快照读,比如使用 FOR UPDATE

(4)、在调用处加 synchronized

Redis分布式锁

分布式锁的本质就是占一个坑,当别的进程也要来占坑时发现已经被占用,就会放弃或者稍后重试

占坑一般采用的是 setnx(set if not exists)指令,只允许一个客户端占坑,先来先占,用完了再调用del指令释放坑

但是如果逻辑执行到中间出现异常,可能导致del执行失败,这样就会陷入死锁,锁永远没办法释放,为了解决这个死锁问题,我们可以再拿到锁时加上一个过期时间,这样的话即使发生死锁,当达到一定的时间后也会自动的释放锁

这样又有一个问题,setnx和expire是两条指令而不是原子指令,如果两条指令之间进程挂掉依然会出现死锁

为了治理上面乱象,在redis 2.8中加入了set指令的扩展参数,使setnx和expire指令可以一起执行

使用Redisson实现分布式锁

Redission这个框架也是重度依赖了Lua脚本和Netty

Lua脚本加锁逻辑

首先去判断这个锁存不存在

如果不存在,那么重入计数设置为1,并且设置过期时间

如果锁存在,并且唯一标识匹配,那么重入计数 + 1,并再次设置锁的过期时间

如果锁存在,但是唯一表示不匹配,那么说明这个锁被其他线程占用,当前线程是没办法解别人的锁的,直接返回过期时间

Lua脚本解锁逻辑

首先判断锁是否存在

如果不存在,则直接广播解锁消息并返回1

如果锁存在,但唯一标识不匹配:则表明锁被其他线程占用,当前线程不允许解锁其他线程持有的锁

若锁存在,且唯一标识匹配:则先将锁重入计数减

整体的流程

1、线程A和线程B两个线程同时争抢锁。线程A很幸运,最先抢到了锁。线程B在获取锁失败后,并未放弃希望,而是主动订阅了解锁消息,然后再尝试获取锁,顺便看看没有抢到的这把锁还有多久就过期,线程B就按需阻塞等锁释放。

2、线程A拿着锁干完了活,自觉释放了持有的锁,于此同时广播了解锁消息,通知其他抢锁的线程再来枪;

3、解锁消息的监听者LockPubSub收到消息后,释放自己持有的信号量;线程B就瞬间从阻塞中被唤醒了,接着再抢锁,这次终于抢到锁了!后面再按部就班,干完活,解锁

3. 秒杀流程

1. 后台添加一个代金券秒杀活动到活动表,同时将这个活动存放在redis缓存中,活动id拼接作为key,同时根据开始时间和结束时间设置过期时间

2. 活动开始后用户点击秒杀按钮,首先将验证码和代金券id以及用户id传给后台,验证验证码,后台根据商品id和用户id生成一个MD5的path,并存入redis,返回给前端(接口隐藏)

3. 前端获取到path,将其作为其中一个参数,然后请求秒杀接口,后台首先验证这个活动是否开始,如果在进行中,那么根据path去对比redis中的path,如果一致,继续扣减库存,否则返回非法请求

4. redis中扣减库存后,将消息塞入消息队列中,利用RocketMQ的异步消息来更新数据库中的库存,下单。以此减轻了数据库的压力。这里引用了事务消息的机制来保证缓存和数据库中数据的一致性。我们需要保证redis扣减库存和发送消息这两个操作的原子性,RocketMQ4.3 之后实现了完整的事务消息。将本地事务消息移动到了MQ内部,在事务提交之前消费者是不可见,等本地事务完成后,会告诉MQ内部消息可以消费,然后消费者再去消费消息。

采用Redisson实分布式锁,采用RocketMQ实现分布式锁。采用令牌桶算法实现限流,采用隐藏接口地址的方法防止提前刷单,采用验证码的方式防止恶意刷单。

  1. 数据库如何同步大批量的数据

迁移脚本方案

第一种方式,在迁移目标服务器跑一个迁移脚本,远程连接源数据服务器的数据库,通过设置查询条件,分块读取源数据,并在读取完之后写入目标数据库。这种迁移方式效率可能会比较低,数据导出和导入相当于是一个同步的过程,需要等到读取完了才能写入。如果查询条件设计得合理,也可以通过多线程的方式启动多个迁移脚本,达到并行迁移的效果。

结合redis搭建一个生产 + 消费的迁移方案。

第二种方式,可以结合redis搭建一个“生产+消费”的迁移方案。源数据服务器可以作为数据生产者,在源数据服务器上跑一个多线程脚本,并行读取数据库里面的数据,并把数据写入到redis队列。目标服务器作为一个消费者,在目标服务器上也跑一个多线程脚本,远程连接redis,并行读取redis队列里面的数据,并把读取到的数据写入到目标数据库。这种方式相对于第一种方式,是一种异步方案,数据导入和数据导出可以同时进行,通过redis做数据的中转站,效率会有较大的提升。

知识总结

欠缺知识

  1. 、MySQL分库分表
  2. 、RocketMQ
  3. 、多线程、并发

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值