Java面试资料
使用Typora软件打开更方便哦
下载地址:
链接:https://pan.baidu.com/s/1aBEPoAWQLj0w9jo2D07pww 提取码:1314 复制这段内容后打开百度网盘手机App,操作更方便哦
“迷失的时候,选择更艰辛的那条路。”
———松浦弥太郎
一、Java基础
1.基本数据类型:
整数型(整数):byte,short,int,long
浮点型(小数):float,double
字符型:char
布尔型:boolean(true,false)
数据类型 | 型别 | 字节 | 取值范围 | 默认值 |
---|---|---|---|---|
byte | 1 | -128(-2^7)---- 127(2^7-1) | 0 | |
short | 2 | -32768(-2^15)---- 32767(2^15 - 1) | 0 | |
int | 4 | -2,147,483,648(-2^31)----2,147,483,647(2^31 - 1) | 0 | |
long | 8 | (-2^63)----(2^63 -1) | 0 | |
float | 4 | 0.0f | ||
double | 8 | 0.0d | ||
char | 2 | 空 | ||
boolean | 1 | false |
2.关键字
2.1 Volatile
作用:
1)Volatile保证不同线程对共享变量操作的可见性,也就是说当一个线程修改了Volatile修饰的变量,当修改写回主内存时,其他线程可以看到最新的值。
2)禁止指令重排序
为了提高性能,编译器和处理器对既定的代码执行顺序进行指令重排序。重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
总结:
- volatile修饰符适⽤于以下场景:某个属性被多个线程共享,其中有⼀个线程修改了此属性,其他线 程可以⽴即得到修改后的值,⽐如booleanflag;或者作为触发器,实现轻量级同步。
- volatile属性的读写操作都是⽆锁的,它不能替代synchronized,因为它没有提供原⼦性和互斥性。 因为⽆锁,不需要花费时间在获取锁和释放锁_上,所以说它是低成本的。
- volatile只能作⽤于属性,我们⽤volatile修饰属性,这样compilers就不会对这个属性做指令重排 序。
- volatile提供了可⻅性,任何⼀个线程对其的修改将⽴⻢对其他线程可⻅,volatile属性不会被线程缓 存,始终从主 存中读取。
- volatile提供了happens-before保证,对volatile变量v的写⼊happens-before所有其他线程后续对v的 读操作。
- volatile可以使得long和double的赋值是原⼦的。
- volatile可以在单例双重检查中实现可⻅性和禁⽌指令重排序,从⽽保证安全性。
2.2Transient
作用:
将不需要序列化的属性前添加关键字transient,序列化对象的时候,这个属性就不会被序列化。
3.==和equals的区别
1.== 解读
对于基本类型和引用类型 == 的作用效果是不同的,如下所示:
基本类型:比较的是值是否相同;
引用类型:比较的是引用是否相同;
代码示例: String x = "string"; String y = "string"; String z = new String("string"); //jvm将其分配到堆内存 System.out.println(x==y); // true System.out.println(x==z); // false System.out.println(x.equals(y)); // true System.out.println(x.equals(z)); // true 解读因为 x 和 y 指向的是同一个引用,所以 == 也是 true,而 new String()方法则重写开辟了内存空间,所以 == 结果为 false,而 equals 比较的一直是值,所以结果都为 true。
2.equals
equals 本质上就是 ==,只不过 String 和 Integer 等重写了 equals 方法,把它变成了值比较。
equals源码解读
public boolean equals(Object anObject) { if (this == anObject) { return true; } if (anObject instanceof String) { String anotherString = (String)anObject; int n = value.length; if (n == anotherString.value.length) { char v1[] = value; char v2[] = anotherString.value; int i = 0; while (n-- != 0) { if (v1[i] != v2[i]) return false; i++; } return true; } }
4.final,finally
- final 修饰类,表示类不可变,不能被继承
- final 修饰方法。表示该方法不可重写
- final 修饰变量,这个变量就是常量
5.方法重写和重载的区别
5.1重写(Override)
从字面上看,重写就是 重新写一遍的意思。其实就是在子类中把父类本身有的方法重新写一遍。子类继承了父类原有的方法,但有时子类并不想原封不动的继承父类中的某个方法,所以在方法名,参数列表,返回类型(除过子类中方法的返回值是父类中方法返回值的子类时)都相同的情况下, 对方法体进行修改或重写,这就是重写。但要注意子类函数的访问修饰权限不能少于父类的。
[代码演示]:
public class Father {
public static void main(String[] args) {
// TODO Auto-generated method stub
Son s = new Son();
s.sayHello();
}
public void sayHello() {
System.out.println("Hello");
}
}
class Son extends Father{
@Override
public void sayHello() {
// TODO Auto-generated method stub
System.out.println("hello by ");
}
}
总结:
- 发生在父类与子类之间
- 方法名,参数列表,返回类型(除过子类中方法的返回类型是父类中返回类型的子类)
- 访问修饰符的权限一定要大于被重写方法的访问修饰符
- 重写方法一定不能抛出新的检查异常或者比被重写方法申明更加宽泛的检查型异常
5.2重载(OverLoad)
在一个类中,同名的方法如果有不同的参数列表(参数类型不同、参数个数不同甚至是参数顺序不同)则视为重载。同时,重载对返回类型没有要求,可以相同也可以不同,但不能通过返回类型是否相同来判断重载。
[代码演示]:
public class Father {
public static void main(String[] args) {
// TODO Auto-generated method stub
Father s = new Father();
s.sayHello();
s.sayHello("wintershii");
}
public void sayHello() {
System.out.println("Hello");
}
public void sayHello(String name) {
System.out.println("Hello" + " " + name);
}
}
总结:
- 重载Overload是一个类中多态性的一种表现
- 重载要求同名方法的参数列表不同(参数类型,参数个数甚至是参数顺序)
- 重载的时候,返回值类型可以相同也可以不相同。无法以返回型别作为重载函数的区分标准
5.3面试时,问:重载(Overload)和重写(Override)的区别?
答:方法的重载和重写都是实现多态的方式,区别在于前者实现的是编译时的多态性,而后者实现的是运行时的多态性。重载发生在一个类中,同名的方法如果有不同的参数列表(参数类型不同、参数个数不同或者二者都不同)则视为重载;重写发生在子类与父类之间,重写要求子类被重写方法与父类被重写方法有相同的参数列表,有兼容的返回类型,比父类被重写方法更好访问,不能比父类被重写方法声明更多的异常(里氏代换原则)。重载对返回类型没有特殊的要求,不能根据返回类型进行区分。
(三)锁
(四)String、StringBuffer、StringBuilder三者的区别
String和StringBuffer、StringBulider的区别在于String声明的是不可变的对象,每次操作都会生成新的String对象,然后将指针指向新的String对象,而StringBuffer、StringBuilder可以在原有对象的基础上操作。
StringBuffer和StringBulider最大的区别在于,StringBuffer是线程安全的,而String Builder是非线程安全的,但StringBuilder的性能却高于StringBuffer,所以在单线程环境下推荐使用StringBuilder;需要在线程安全的情况下推荐使用StringBuilder.
源码示例
//String
public final class String
{
private final char value[];
public String(String original) {
// 把原字符串original切分成字符数组并赋给value[];
}
}
//StringBuffer
public final class StringBuffer extends AbstractStringBuilder
{
char value[]; //继承了父类AbstractStringBuilder中的value[]
public StringBuffer(String str) {
super(str.length() + 16); //继承父类的构造器,并创建一个大小为str.length()+16的value[]数组
append(str); //将str切分成字符序列并加入到value[]中
}
}
(五)序列化、反序列化
序列化与反序列化的定义:
(1)Java序列化就是指把Java对象转换为字节序列的过程
作用:在传递和保存对象时,保证对象的完整性和可传递性。对象转换为有序字节流时,便于在网络上传输或者存储在本地文件中。
(2)Java反序列化就是指把字节序列恢复为Java对象的过程
作用:根据字节流保存的对象状态以及描述信息,通过反序列化来重建对象。
实现序列化和反序列化的步骤
①实现序列化的必备要求:
实现了Serializable或者Externalizable接口的类的对象才能被序列化为字节序列。
②JDK中序列化和反序列化的API:
java.io.ObjectInputStream:对象输入流。
该类的readObject()方法从输入流中读取字节序列,然后将字节序列反序列化为一个对象并返回。
java.io.ObjectOutputStream:对象输出流。
该类的writeObject(Object obj)方法将将传入的obj对象进行序列化,把得到的字节序列写入到目标输出流中进行输出。
[代码演示:]:
@Data
public Class Student implement Serializable{
//序列化id
private static final long serialVersionUID = -4060343097263809614L;
private String userName;
private String password;
private String age;
}
--------------------------------------------------------------------------------------
public class SerializableTest {
public static void main(String[] args) throws IOException, ClassNotFoundException {
//序列化
FileOutputStream fos = new FileOutputStream("object.out");
ObjectOutputStream oos = new ObjectOutputStream(fos);
Student fsq = new Student("冯小猪", "5201314", "22");
oos.writeObject(fsq);
oos.flush();
//关闭资源
oos.close();
//反序列化
FileInputStream fis = new FileInputStream("object.out");
ObjectInputStream ois = new ObjectInputStream(fis);
Student pig = (Student) ois.readObject();
System.out.println(pig.getUserName()+ " " +pig.getPassword() + " " +pig.getAge());
}
}
二、JVM
1.JVM运行时内存区域
程序计数器:当CPU切换线程时,程序从上次执行位置开始(保存现场)
方法区(元空间):常量+静态变量+类信息
1.程序计数器(Program Counter Register):程序计数器是一个比较小的内存区域,用于指示当前线程所执行的字节码执行到了第几行,可以理解为是当前线程的行号指示器。字节码解释器在工作时,会通过改变这个计数器的值来取下一条语句指令。
每个程序计数器只用来记录一个线程的行号,所以它是线程私有(一个线程就有一个程序计数器)的。
如果程序执行的是一个Java方法,则计数器记录的是正在执行的虚拟机字节码指令地址;如果正在执行的是一个本地(native,由C语言编写完成)方法,则计数器的值为Undefined,由于程序计数器只是记录当前指令地址,所以不存在内存溢出的情况,因此,程序计数器也是所有JVM内存区域中唯一一个没有定义OutOfMemoryError的区域。
2.虚拟机栈(JVM Stack):一个线程的每个方法在执行的同时,都会创建一个栈帧(Statck Frame),栈帧中存储的有局部变量表、操作站、动态链接、方法出口等,当方法被调用时,栈帧在JVM栈中入栈,当方法执行完成时,栈帧出栈。
局部变量表中存储着方法的相关局部变量,包括各种基本数据类型,对象的引用,返回地址等。在局部变量表中,只有long和double类型会占用2个局部变量空间(Slot,对于32位机器,一个Slot就是32个bit),其它都是1个Slot。需要注意的是,局部变量表是在编译时就已经确定好的,方法运行所需要分配的空间在栈帧中是完全确定的,在方法的生命周期内都不会改变。
每个线程对应着一个虚拟机栈,因此虚拟机栈也是线程私有的。
3.本地方法栈(Native Method Statck):本地方法栈在作用,运行机制,异常类型等方面都与虚拟机栈相同,唯一的区别是:虚拟机栈是执行Java方法的,而本地方法栈是用来执行native方法的,在很多虚拟机中(如Sun的JDK默认的HotSpot虚拟机),会将本地方法栈与虚拟机栈放在一起使用。
本地方法栈也是线程私有的。
4.堆区(Heap):堆区是理解Java GC机制最重要的区域,没有之一。在JVM所管理的内存中,堆区是最大的一块,堆区也是Java GC机制所管理的主要内存区域,堆区由所有线程共享,在虚拟机启动时创建。堆区的存在是为了存储对象实例,原则上讲,所有的对象都在堆区上分配内存(不过现代技术里,也不是这么绝对的,也有栈上直接分配的)。
一般的,根据Java虚拟机规范规定,堆内存需要在逻辑上是连续的(在物理上不需要),在实现时,可以是固定大小的,也可以是可扩展的,目前主流的虚拟机都是可扩展的。如果在执行垃圾回收之后,仍没有足够的内存分配,也不能再扩展,将会抛出OutOfMemoryError:Java heap space异常。
5.方法区(Method Area):
方法区是各个线程共享的区域,用于存储已经被虚拟机加载的类信息(即加载类时需要加载的信息,包括版本、field、方法、接口等信息)、final常量、静态变量、编译器即时编译的代码等。
2JVM创建对象的步骤
**第一步:**虚拟机遇到一个new指令,首先去检查这个指令的参数能否在常量池中定位到这个类的符号引用,并且检查这个符号引用的类是否已经被加载&解析&初始化。
**第二步:**如果类已经被加载直接进行第三部;如果没有加载,就进行类的加载。
**第三步:**类加载检查通过后,给新对象分配内存空间。
**第四步:**对象生成需要的内存大小在类加载完成之后便可完全确认,为对象分配空间等同于把一块确定大小的内存从堆里划分出来。
**第五步:**内存大小的划分分为两种情况:
第一种情况:JVM的内存是规整的,所有的使用的内存都放到一边,空闲的内存在另外一边,中间放一个指针作为分界点的指示器。那么这时候分配内存就比较简单,只要讲指针向空闲空间那边挪动一段与对象大小相同的距离。这种就是“指针碰撞”。
第二种情况:JVM的内存不是规整的,也就是说已使用的内存与未使用的内存相互交错。这时候就没办法利用指正碰撞了。这时候我们就需要维护一张表,用于记录那些内存可用,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新到记录表上。
**第六步:**空间申请完成之后,JVM需要将内存的空间都初始化为0值。
**第七步:**JVM对对象进行必要的设置。例如:该对象是那个类的实例、对象的哈希码、GC年代信息等。
**第八步:**执行《init》方法,按照程序中设定的初始化操作来初始化对象。
3.类加载机制
1.加载
加载是类加载的第一个过程,在这个阶段,将完成一下三件事情:
• 通过一个类的全限定名获取该类的二进制流。
• 将该二进制流中的静态存储结构转化为方法去运行时数据结构。
• 在内存中生成该类的 Class 对象,作为该类的数据访问入口。
2.验证
验证的目的是为了确保 Class 文件的字节流中的信息不回危害到
虚拟机.在该阶段主要完成以下四钟验证:
• 文件格式验证:验证字节流是否符合 Class 文件的规范,如主次版本号是否在当前虚拟机范围内,常量池中的常量是否有不被支持的类型.
• 元数据验证:对字节码描述的信息进行语义分析,如这个类是
否有父类,是否集成了不被继承的类等。
• 字节码验证:是整个验证过程中最复杂的一个阶段,通过验证数据流和控制流的分析,确定程序语义是否正确,主要针对方法体的验证。如:方法中的类型转换是否正确,跳转指令是否正确等。
• 符号引用验证:这个动作在后面的解析过程中发生,主要是为了确保解析动作能正确执行。
3.准备
准备阶段是为类的静态变量分配内存并将其初始化为默认值,这些内存都将在方法区中进行分配。准备阶段不分配类中的实例变量的内存,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中
public static int value=676;//在准备阶段 value 初始值为 0 。在初始化阶段才会变为 676 。
...
4.GC垃圾回收机制
1)如何找到需要回收的对象
1、**引用计数法:**给对象中添加一个引用计数器,每当一个地方引用这个对象时,计数器值+1;当引用失效时,计数器值-1。任何时刻计数值为0的对象就是不可能再被使用的。
2、**可达性分析法:**对于可达性分析算法而言,未到达的对象并非是“非死不可”的,若要宣判一个对象死亡,至少需要经历两次标记阶段。
如果对象在进行可达性分析后发现没有与GCRoots相连的引用链,则该对象被第一次标记并进行一次筛选,筛选条件为是否有必要执行该对象的finalize方法,若对象没有覆盖finalize方法或者该finalize方法已经被虚拟机执行过了( finalize()在什么时候被调用? 有三种情况 1.所有对象被Garbage Collection时自动调用,比如运行System.gc()的时候. 2.程序退出时为每个对象调用一次finalize方法。 3.显式的调用finalize方法),则均视作不必要执行该对象的finalize方法,即该对象将会被回收。反之,若对象覆盖了finalize方法并且该finalize方法并没有被执行过,那么,这个对象会被放置在一个叫F-Queue的队列中,之后会由虚拟机自动建立的、优先级低的Finalizer线程去执行,而虚拟机不必要等待该线程执行结束,即虚拟机只负责建立线程,其他的事情交给此线程去处理。
2.对F-Queue中对象进行第二次标记,如果对象在finalize方法中拯救了自己,即关联上了GCRoots引用链,如把this关键字赋值给其他变量,那么在第二次标记的时候该对象将从“即将回收”的集合中移除,如果对象还是没有拯救自己,那就会被回收。它只能拯救自己一次,第二次就被回收了。此外,从我们可以得知,一个堆对象的this引用会永远存在,在方法体内可以将this引用赋值给其他变量,这样堆中对象就可以被其他变量所引用,即不会被回收。
2)垃圾收集算法
(1)Mark-Sweep标记清理算法
阶段1: Mark-Sweep 标记清除阶段,先假设heap中所有对象都可以回收,然后找出不能回收的对象,给这些对象打上标记,最后heap中没有打标记的对象都是可以被回收的;
阶段2: Compact 压缩阶段,对象回收之后heap内存空间变得不连续,在heap中移动这些对象,使他们重新从heap基地址开始连续排列(节省内存资源)。
Heap内存经过回收、压缩之后,可以继续采用前面的heap内存分配方法, 即仅用一个指针记录heap分配的起始地址就可以。主要处理步骤:将线程挂起→确定roots→创建reachable objects graph→对象回收→heap压缩→指针修复。可以这样理解roots:heap中对象的引用关系错综复杂(交叉引用、循环引用),形成复杂的 graph,roots是CLR在heap之外可以找到的各种入口点。
GC搜索roots的地方包括全局对象、静态变量、局部对象、函数调用参数、当前CPU寄存器中的对象指针(还有finalization queue)等。主要可以归为2种类型:已经初始化了的静态变量、线程仍在使用的对象(stack+CPU register)
指针修复是因为compact过程移动了heap对象,对象地址发生变化,需要修复所有引用指针,包括stack、CPU register中的指针以及heap中其他对象的引用指针。
(2)复制算法
新生代的内存被划分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。每次回收时,将Eden和Survivor中还存活着的对象一次性复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。HotSpot虚拟机默认Eden区和Survivor区的比例为8:1,意思是每次新生代中可用内存空间为整个新生代容量的90%。当然,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖老年代进行分配担保(Handle Promotion)。
(3)标记整理算法
与标记清理算法过程一样,只是不直接清理可回收对象,而是将所有存活对象移动到一端,之后清理边界之外的对象内存
(4)分代收集算法
现代商用虚拟机基本都采用分代收集算法来进行垃圾回收。这种算法没什么特别的,无非是上面内容的结合罢了,根据对象的生命周期的不同将内存划分为几块,然后根据各块的特点采用最适当的收集算法。大批对象死去、少量对象存活的(新生代),使用复制算法,复制成本低;对象存活率高、没有额外空间进行分配担保的(老年代),采用标记-清理算法或者标记-整理算法。
3)垃圾回收流程
1、新建的对象,大部分存储在Eden中 2、当Eden内存不够,就进行Minor GC释放掉不活跃对象;然后将部分活跃对象复制到Survivor中(如Survivor1),同时清空Eden区 3、当Eden区再次满了,将Survivor1中不能清空的对象存放到另一个Survivor中(如Survivor2),同时将Eden区中的不能清空的对象,复制到Survivor1,同时清空Eden区 4、重复多次(默认15次):Survivor中没有被清理的对象就会复制到老年区(Old) 5、当Old达到一定比例,则会触发Major GC释放老年代 6、当Old区满了,则触发一个一次完整的垃圾回收(Full GC) 7、如果内存还是不够,JVM会抛出内存不足,发生oom,内存泄漏。
三、Java集合
四、多线程
五、MySQL
(一)存储引擎
1.InnoDB
InnoDB 是 MySQL 默认的事务型存储引擎,只要在需要它不支持的特性时,才考虑使用其他存储引擎。
InnoDB 采用 MVCC 来支持高并发,并且实现了四个标准隔离级别(未提交读、提交读、可重复读、可串行化)。其默认级别时可重复读(REPEATABLE READ),在可重复读级别下,通过 MVCC + Next-Key Locking 防止幻读。
主索引时聚簇索引,在索引中保存了数据,从而避免直接读取磁盘,因此对主键查询有很高的性能。
InnoDB 内部做了很多优化,包括从磁盘读取数据时采用的可预测性读,能够自动在内存中创建 hash 索引以加速读操作的自适应哈希索引,以及能够加速插入操作的插入缓冲区等。
InnoDB 支持真正的在线热备份,MySQL 其他的存储引擎不支持在线热备份,要获取一致性视图需要停止对所有表的写入,而在读写混合的场景中,停止写入可能也意味着停止读取。
2.MyISAM
设计简单,数据以紧密格式存储。对于只读数据,或者表比较小、可以容忍修复操作,则依然可以使用它。
提供了大量的特性,包括压缩表、空间数据索引等。
不支持事务。
不支持行级锁,只能对整张表加锁,读取时会对需要读到的所有表加共享锁,写入时则对表加排它锁。但在表有读取操作的同时,也可以往表中插入新的记录,这被称为并发插入(CONCURRENT INSERT)。
可以手工或者自动执行检查和修复操作,但是和事务恢复以及崩溃恢复不同,可能导致一些数据丢失,而且修复操作是非常慢的。
如果指定了 DELAY_KEY_WRITE 选项,在每次修改执行完成时,不会立即将修改的索引数据写入磁盘,而是会写到内存中的键缓冲区,只有在清理键缓冲区或者关闭表的时候才会将对应的索引块写入磁盘。这种方式可以极大的提升写入性能,但是在数据库或者主机崩溃时会造成索引损坏,需要执行修复操作。
3.InnoDB 和 MyISAM 的比较
- 事务:InnoDB 是事务型的,可以使用 Commit 和 Rollback 语句。
- 并发:MyISAM 只支持表级锁,而 InnoDB 还支持行级锁。
- 外键:InnoDB 支持外键。
- 备份:InnoDB 支持在线热备份。
- 崩溃恢复:MyISAM 崩溃后发生损坏的概率比 InnoDB 高很多,而且恢复的速度也更慢。
- 其它特性:MyISAM 支持压缩表和空间数据索引。
(二)索引
1.B+ Tree 索引
是大多数 MySQL 存储引擎的默认索引类型。
- 因为不再需要进行全表扫描,只需要对树进行搜索即可,所以查找速度快很多。
- 因为 B+ Tree 的有序性,所以除了用于查找,还可以用于排序和分组。
- 可以指定多个列作为索引列,多个索引列共同组成键。
- 适用于全键值、键值范围和键前缀查找,其中键前缀查找只适用于最左前缀查找。如果不是按照索引列的顺序进行查找,则无法使用索引。
InnoDB 的 B+Tree 索引分为主索引和辅助索引。主索引的叶子节点 data 域记录着完整的数据记录,这种索引方式被称为聚簇索引。因为无法把数据行存放在两个不同的地方,所以一个表只能有一个聚簇索引。
辅助索引的叶子节点的 data 域记录着主键的值,因此在使用辅助索引进行查找时,需要先查找到主键值,然后再到主索引中进行查找,这个过程也被称作回表。
2.哈希索引
哈希索引能以 O(1) 时间进行查找,但是失去了有序性:
- 无法用于排序与分组;
- 只支持精确查找,无法用于部分查找和范围查找。
InnoDB 存储引擎有一个特殊的功能叫“自适应哈希索引”,当某个索引值被使用的非常频繁时,会在 B+Tree 索引之上再创建一个哈希索引,这样就让 B+Tree 索引具有哈希索引的一些优点,比如快速的哈希查找。
3.全文索引
MyISAM 存储引擎支持全文索引,用于查找文本中的关键词,而不是直接比较是否相等。
查找条件使用 MATCH AGAINST,而不是普通的 WHERE。
全文索引使用倒排索引实现,它记录着关键词到其所在文档的映射。
InnoDB 存储引擎在 MySQL 5.6.4 版本中也开始支持全文索引。
4.空间数据索引
MyISAM 存储引擎支持空间数据索引(R-Tree),可以用于地理数据存储。空间数据索引会从所有维度来索引数据,可以有效地使用任意维度来进行组合查询。
必须使用 GIS 相关的函数来维护数据。
补充.B+ Tree 原理
1)数据结构
B Tree 指的是 Balance Tree,也就是平衡树,平衡树是一颗查找树,并且所有叶子节点位于同一层。
B+ Tree 是 B 树的一种变形,它是基于 B Tree 和叶子节点顺序访问指针进行实现,通常用于数据库和操作系统的文件系统中。
B+ 树有两种类型的节点:内部节点(也称索引节点)和叶子节点,内部节点就是非叶子节点,内部节点不存储数据,只存储索引,数据都存在叶子节点。
内部节点中的 key 都按照从小到大的顺序排列,对于内部节点中的一个 key,左子树中的所有 key 都小于它,右子树中的 key 都大于等于它,叶子节点的记录也是按照从小到大排列的。
每个叶子节点都存有相邻叶子节点的指针。
(三)事务
1.概念
事务是数据库管理系统(DBMC)执行过程中的一个逻辑单位,由一个有限的数据库操作序列构成。
2.事务的四大特性
Atomicity:原子性
事务被视为不可分割的最小单元,事务的所有操作要么全部成功,要么全部失败回滚。
Consistency:一致性
数据库在事务执行前后都保持一致性状态,在一致性状态下,所有事务对一个数据的读取结果都是相同的。
Isolation:隔离性
一个事务所做的修改在最终提交以前,对其他事务是不可见的。
Durability:持久性
一旦事务提交,则其所做的修改将会永远保存到数据库中。即使系统发生崩溃,事务执行的结果也不能丢。
3.事务并发的三大问题
1)脏读
2)不可重复读
3)幻读
4.事务的四种隔离级别
未提交读(READ UNCOMMITTED)
事务中的修改,即使没有提交,对其他事务也是可见的。
提交读(READ COMMITTED):解决脏读
一个事务只能读取已经提交的事务所做的修改。换句话说,一个事务所做的修改在提交之前对其他事务是不可见的。
可重复读(REPEATABLE READ):解决脏读、不可重复读
保证在同一个事务中多次读取同样数据的结果是一样的。
可串行化(SERIALIZABLE):解决事务并发的所有问题
强制事务串行执行。
补充:InnoDB事务隔离级别的实现
五、MyBatis
六、Spring
(一)IOC
1.IOC(控制反转)
IOC(控制反转),就是依赖倒置原则的一种代码设计的思路。将类的创建和依赖关系写在配置文件里,由配置文件注入,实现了松耦合。传统的创建对象的方法是直接通过 new 关键字,而 spring 则是通过 IOC 容器来创建对象,也就是说我们将创建对象的控制权交给了 IOC 容器。IOC 让程序员不再关注怎么去创建对象,而是关注与对象创建之后的操作,把对象的创建、初始化、销毁等工作交给spring容器来做。
控制的是什么?对象生命周期。创建、管理、销毁。
反转的是什么?对象的生命周期控制权。没有IoC是代码控制生命周期。有IoC是Spring容器控制生命周期。
2.DI(依赖注入)
依赖注入(Dependecy Injection)具体的讲:当某个角色需要另外一个角色协助的时候,在传统的程序设计过程中,通常由调用者来创建被调用者的实例。但在spring中创建被调用者的工作不再由调用者来完成,因此称为控制反转。创建被调用者的工作由spring来完成,然后注入调用者。
2.1Spring中依赖注入的方式
1.构造方法注入
构造方法注入是指在构造方法中注入属性或者对象来实现依赖注入,如下所示,在标签中定义一个id为userDaoImpl的Bean,并通过注入了name为username,value为admin的值,注入完成后直接通过this.username获取到值admin。其中引用类型使用ref属性,基本类型使用value属性。
public class UserDaoImpl {
private String username;
public UserDaoImpl(String username) {
this.username = username;
}
}
<bean id="userDaoImpl" class="com.example.UserDaoImpl">
<constructor-arg name="username" value="admin"></constructor-arg>
</bean>
2.SET方法注入
set方法注入就是通过在类中实现get、set方法来实现属性或者对象的依赖注入,如下所示,在标签中定义一个id为userDaoImpl的Bean,并通过注入了name为username,value为admin的值,注入完成后直接通过getUsername()获取到值admin。
public class UserDaoImpl {
private String username;
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
}
<bean id="userDaoImpl" class="com.example.UserDaoImpl">
<property name="username" value="admin"></property>
</bean>
2.2@Autowired和@Resource两个注解的区别
1) @Autowired默认按照byType方式进行bean匹配,@Resource默认按照byName方式进行bean匹配
2) @Autowired是Spring的注解,@Resource是J2EE的注解,
(二)AOP
1.什么是AOP
AOP(Aspect-OrientedProgramming,面向切面编程),可以说是OOP(Object-Oriented Programing,面向对象编程)的补充和完善。OOP引入封装、继承和多态性等概念来建立一种对象层次结构,用以模拟公共行为的一个集合。当我们需要为分散的对象引入公共行为的时候,OOP则显得无能为力。也就是说,OOP允许你定义从上到下的关系,但并不适合定义从左到右的关系。例如日志功能。日志代码往往水平地散布在所有对象层次中,而与它所散布到的对象的核心功能毫无关系。对于其他类型的代码,如安全性、异常处理和透明的持续性也是如此。这种散布在各处的无关的代码被称为横切(cross-cutting)代码,在OOP设计中,它导致了大量代码的重复,而不利于各个模块的重用。
而AOP技术则恰恰相反,它利用一种称为“横切”的技术,剖解开封装的对象内部,并将那些影响了多个类的公共行为封装到一个可重用模块,并将其名为“Aspect”,即切面。所谓“切面”,简单地说,就是将那些与业务无关,却为业务模块所共同调用的逻辑或责任封装起来,便于减少系统的重复代码,降低模块间的耦合度,并有利于未来的可操作性和可维护性。AOP代表的是一个横向的关系,如果说“对象”是一个空心的圆柱体,其中封装的是对象的属性和行为;那么面向方面编程的方法,就仿佛一把利刃,将这些空心圆柱体剖开,以获得其内部的消息。而剖开的切面,也就是所谓的“方面”了。然后它又以巧夺天功的妙手将这些剖开的切面复原,不留痕迹。
2.aop的基本概念
- (1)Aspect(切面):通常是一个类,里面可以定义切入点和通知
- (2)JointPoint(连接点):程序执行过程中明确的点,一般是方法的调用
- (3)Advice(通知):AOP在特定的切入点上执行的增强处理,有before,after,afterReturning,afterThrowing,around
- (4)Pointcut(切入点):就是带有通知的连接点,在程序中主要体现为书写切入点表达式
- (5)AOP代理:AOP框架创建的对象,代理就是目标对象的加强。Spring中的AOP代理可以使JDK动态代理,也可以是CGLIB代理,前者基于接口,后者基于子类
通知方法
- 前置通知:在我们执行目标方法之前运行(@Before)
- 后置通知:在我们目标方法运行结束之后 ,不管有没有异常(@After)
- 返回通知:在我们的目标方法正常返回值后运行(@AfterReturning)
- 异常通知:在我们的目标方法出现异常后运行***(@AfterThrowing)***
- 环绕通知:动态代理, 需要手动执行joinPoint.procced()(其实就是执行我们的目标方法执行之前相当于前置通知, 执行之后就相当于我们后置通知**(@Around)**
3.aop术语
- 连接点(Joinpoint) 程序执行的某个特定位置,如某个方法调用前,调用后,方法抛出异常后,这些代码中的特定点称为连接点。简单来说,就是在哪加入你的逻辑增强 连接点表示具体要拦截的方法,上面切点是定义一个范围,而连接点是具体到某个方法
- 切点(PointCut) 每个程序的连接点有多个,如何定位到某个感兴趣的连接点,就需要通过切点来定位。比如,连接点--数据库的记录,切点--查询条件 切点用于来限定Spring-AOP启动的范围,通常我们采用表达式的方式来设置,所以关键词是范围
- 增强(Advice) 增强是织入到目标类连接点上的一段程序代码。在Spring中,像BeforeAdvice等还带有方位信息 通知是直译过来的结果,我个人感觉叫做**“业务增强”**更合适 对照代码就是拦截器定义的相关方法,通知分为如下几种: 前置通知(before):在执行业务代码前做些操作,比如获取连接对象 后置通知(after):在执行业务代码后做些操作,无论是否发生异常,它都会执行,比如关闭连接对象 异常通知(afterThrowing):在执行业务代码后出现异常,需要做的操作,比如回滚事务 返回通知(afterReturning),在执行业务代码后无异常,会执行的操作 环绕通知(around),这个目前跟我们谈论的事务没有对应的操作,所以暂时不谈
- 目标对象(Target) 需要被加强的业务对象
- 织入(Weaving) 织入就是将增强添加到对目标类具体连接点上的过程。 织入是一个形象的说法,具体来说,就是生成代理对象并将切面内容融入到业务流程的过程。
- 代理类(Proxy) 一个类被AOP织入增强后,就产生了一个代理类。
- 切面(Aspect) 切面由切点和增强组成,它既包括了横切逻辑的定义,也包括了连接点的定义,SpringAOP就是将切面所定义的横切逻辑织入到切面所制定的连接点中。 比如上文讨论的数据库事务,这个数据库事务代码贯穿了我们的整个代码,我们就可以这个叫做切面。 SpringAOP将切面定义的内容织入到我们的代码中,从而实现前后的控制逻辑。 比如我们常写的拦截器Interceptor,这就是一个切面类
4.Spring AOP实现-基于注解的方式
@Asoect创建切面类
https://blog.csdn.net/qq_41981107/article/details/87920537
https://blog.csdn.net/JinXYan/article/details/89302126
七、Spring MVC
(一)什么是springmvc
SpringMvc 是 Spring 框架的一个模块, SpringMvc 和 Spring 无需通过中间整合层进行整合。
SpringMvc 是基于 MVC 的一个 Web 框架。
(二)Spring MVC执行原理
- 用户发送请求至前端控制器 DispatcherServlet
- DispatcherServlet 收到请求调用 HandlerMapping 处理映射器。
- 处理映射器根据请求 url 找到具体的处理器,生成处理器对象及处理器拦截器(如果有则生成)一并返回给 DispatcherServlet 。
- DispatcherServlet 通过 HandlerAdapter 处理器适配器调用处理器。
- 执行处理器(Controller,也叫后端处理器)执行完成返回ModelAndVIew.
- HandlerAdapter 将 Controller 执行结果 ModelAndView 返回给 DispatcherServlet
- DispatcherServlet 将ModelAndView 传给 ViewReslover 视图解析器
- ViewReslover 解析后返回具体 View
- DispatcherServlet 对 View 进行渲染视图(即将模型数据填充至视图中)
- DispatcherServlet 响应用户
八、Spring Boot
九、SpringCloud微服务治理平台
1.Eureka 架构
基本工作流程:
1.Eureka Server 启劢成功,等待服务端注册。在启劢过程中如果配置了集群,集群之间定时通过 Replicate 同步注册表,每个 Eureka Server 都存在独立完整的服务注册表信息
2.Eureka Client启动时根据配置的Eureka Server地址去注册中心注册服务
3.Eureka Client 每隔30s向Eureka Server发送一次心跳请求,证明客户端服务正常。
4.Eureka Server 90s内如果没收到心跳,注册中心则认为该节点失效,会注销该实例
5.单位时间内Eureka Server统计到有大量Eureka Client 没有上送心跳请求,则认为存在网络异常,进入自我保护机制,不再剔除没有上送心跳的客户端
6.当 Eureka Client 心跳请求恢复正常之后,Eureka Server 自劢退出自我保护模式
7.Eureka Client 定时全量戒者增量从注册中心获取服务注册表,并且将获取到的信息缓存到本地
8.服务调用时,Eureka Client 会先从本地缓存找寻调取的服务。如果获取不到,先从注册中心刷新注册表,再同步到本地缓存
9.Eureka Client 获取到目标服务器信息,发起服务调用
10.Eureka Client 程序关闭时向 Eureka Server 发送取消请求,Eureka Server 将实例从注册表中删除
2.CAP理论
著名的CAP理论指出,一个分布式系统不可能同时满足C(一致性)、A(可用性)和P(分区容错性)。由于分区容错性在是分布式系统中必须要保证的,因此我们只能在A和C之间进行权衡。在此Zookeeper保证的是CP, 而Eureka则是AP
C(一致性):在分布式系统中,是否立即达到数据同步的效果(平时多说的强一致性)。在分布式系统一定最终会一致的。如果请求时,整个分布式系统同步后才返回结果,叫做强一致性(满足一致性)。如果先返回结果,在一定时间后才实现一致性就叫做弱一致性。
A(可用性):在分布式系统中,其中一些节点出现问题,整个整体是否还可用。
P(分区容错性):在分布式系统中,是否可以在有限的时间内达到数据一致的效果,如果因为网络等问题最终没有达到一致性,这时称为出现分区错误。
3.Eureka与Zookeeper的区别
Ribbon模型|架构
==实现负载均衡(作用于客户端)==
OpenFeign
Hystris
(一)背景
在分布式系统环境下,应用服务之间的相互依赖是非常常见的,一个应用可能会同时依赖好几个基础服务。基础服务往往由多方维护,没办法同时保证所有基础服务在任何环境下都是绝对的稳定。如下图,对于同步调用,当库存服务不可用时,商品服务请求线程被阻塞,当有大批量请求调用库存服务时,最终可能导致整个商品服务资源耗尽,无法继续对外提供服务。并且这种不可用可能沿请求调用链向上传递,这种现象被称为雪崩效应。
服务依赖解决了什么
复杂分布式体系结构中的应用程序有许多依赖项,每个依赖项在某些时候都不可避免地会失败。如果主机应用程序没有与这些外部故障隔离,那么它有可能被他们拖垮。
例如,对于一个依赖于30个服务的应用程序,每个服务都有99.99%的正常运行时间,你可以期望如下:
99.9930 = 99.7% 可用
也就是说一亿个请求的0.03% = 3000000 会失败
如果一切正常,那么每个月有2个小时服务是不可用的
现实通常是更糟糕
当一切正常时,请求看起来是这样的:
当其中有一个系统有延迟时,它可能阻塞整个用户请求:
在高流量的情况下,一个后端依赖项的延迟可能导致所有服务器上的所有资源在数秒内饱和(PS:意味着后续再有请求将无法立即提供服务)
基本的容错模式
断路器模式
断路流程
断路器内核
如果请求数达到了设置的请求阈值或者请求失败的比例超过了设置的比例,则断路器将从close状态转到open状态,这时所有的请求都会被阻止。 2、sleep一段时间后,下一个请求将被放过,这时断路器处于半开半闭状态,目的是为了验证一下后边的路是否通畅,如果请求失败,则断路器回到open状态;如果成功了则断路器切换到closed状态并且返回响应的结果。
图中描述了计数器维护的数据存储结构及工作原理:大概意思是它维护10个桶(bucket),每个桶中记录第i秒请求状态(success、failure、timeout、rejection)的数量,当新的一秒请求记录来的时候,计数器会丢掉时间最靠前的桶。
SpringCloud简单集成
model-1:eureka-server
Application.java
@EnableEurekaServer
@SpringBootApplication
public class Application {
public static void main(String[] args) {
new SpringApplicationBuilder(Application.class).web(true).run(args);
}
}
application.properties
spring.application.name=eureka-server
server.port=1001
eureka.instance.hostname=localhost
eureka.client.register-with-eureka=false
eureka.client.fetch-registry=false
logging.file=${spring.application.name}.log
model-2:eureka-consumer-ribbon-hystrix
Application.java
@EnableCircuitBreaker
@EnableDiscoveryClient
@SpringBootApplication
public class Application {
@Bean
@LoadBalanced
public RestTemplate restTemplate() {
return new RestTemplate();
}
public static void main(String[] args) {
new SpringApplicationBuilder(Application.class).web(true).run(args);
}
}
DcController.java
@RestController
public class DcController {
@Autowired
ConsumerService consumerService;
@GetMapping("/consumer")
public String dc() {
return consumerService.consumer();
}
@Service
class ConsumerService {
@Autowired
RestTemplate restTemplate;
@HystrixCommand(fallbackMethod = "fallback")
public String consumer() {
return restTemplate.getForObject("http://eureka-client/dc", String.class);
}
public String fallback() {
return "fallbck";
}
}
}
application.properties
spring.application.name=eureka-consumer-ribbon-hystrix
server.port=2101
eureka.client.serviceUrl.defaultZone=http://localhost:1001/eureka/
logging.file=${spring.application.name}.log
model-3:eureka-client
Application.java
@EnableDiscoveryClient
@SpringBootApplication
public class Application {
public static void main(String[] args) {
new SpringApplicationBuilder(Application.class).web(true).run(args);
}
}
DcController.java
@RestController
public class DcController {
@Autowired
DiscoveryClient discoveryClient;
@GetMapping("/dc")
public String dc() {
String services = "Services: " + discoveryClient.getServices();
System.out.println(services);
return services;
}
}
application.properties
spring.application.name=eureka-client
server.port=2001
eureka.client.serviceUrl.defaultZone=http://localhost:1001/eureka/
#eureka.client.serviceUrl.defaultZone=http://peer1:1001/eureka/,http://peer2:1002/eureka/
logging.file=${spring.application.name}.log
MQ
()常见面试题
1.你们为什么使用mq?具体的使用场景是什么?
mq的作用很简单,削峰填谷。以电商交易下单的场景来说,正向交易的过程可能涉及到创建订单、扣减库存、扣减活动预算、扣减积分等等。每个接口的耗时如果是100ms,那么理论上整个下单的链路就需要耗费400ms,这个时间显然是太长了。
如果这些操作全部同步处理的话,首先调用链路太长影响接口性能,其次分布式事务的问题很难处理,这时候像扣减预算和积分这种对实时一致性要求没有那么高的请求,完全就可以通过mq异步的方式去处理了。同时,考虑到异步带来的不一致的问题,我们可以通过job去重试保证接口调用成功,而且一般公司都会有核对的平台,比如下单成功但是未扣减积分的这种问题可以通过核对作为兜底的处理方案。
2.那你们使用什么mq?基于什么做的选型?
我们主要调研了几个主流的mq,kafka、rabbitmq、rocketmq、activemq,选型我们主要基于以下几个点去考虑:
- 由于我们系统的qps压力比较大,所以性能是首要考虑的要素。
- 开发语言,由于我们的开发语言是java,主要是为了方便二次开发。
- 对于高并发的业务场景是必须的,所以需要支持分布式架构的设计。
- 功能全面,由于不同的业务场景,可能会用到顺序消息、事务消息等。
你上面提到异步发送,那消息可靠性怎么保证?
1.Message |
---|
消息。消息是不具名的,它由消息头消息体组成。消息体是不透明的,而消息头则由一系列可选属性组成,这些属性包括:routing-key(路由键)、priority(相对于其他消息的优先权)、delivery-mode(指出消息可能持久性存储)等。 |
2.Publisher |
消息的生产者。也是一个向交换器发布消息的客户端应用程序。通俗说明:哪些项目向RabbitMQ发送消息,哪些项目就是Publisher |
3.Consumer |
消息的消费者。表示一个从消息队列中取得消息的客户端应用程序。Consumer会一直监听指定的队列,只要队列中有消息,就会按照顺序依次取出。使用MQ做耗时任务时,耗时任务就交给Consumer进行完成。 |
4.Exchange |
交换器。用来接收生产者发送的消息并将这些消息路由给服务器中的队列。 一共支持四种的交换器类型 1. direct(发布与订阅 完全匹配) 2. fanout(广播) 3. topic(主题,规则匹配)4. header(使用较少,相比direct就多了一些头信息) |
5.Binding |
绑定。用于消息队列和交换器之间的关联。一个绑定就是基于路由键将交换器和消息队列连接起来的路由规则,所以可以将交换器理解成一个由绑定构成的路由表。一个交换器里面可以绑定多个队列。一个队列一般都是只绑定到一个交换器上。消息发送给交换器,交换器会把效果按照特定规则发送给绑定的队列。 |
6.Queue |
消息队列。用来保存消息直到发送给消费者。它是消息的容器,也是消息的终点。一个消息可投入一个或多个队列。消息一直在队列里面,等待消费者连接到这个队列将其取走。 |
7.Routing-key |
路由键。RabbitMQ决定消息该投递到哪个队列的规则。(也可以理解为队列的名称,路由键是key,队列是value)队列通过路由键绑定到交换器。消息发送到MQ服务器时,消息将拥有一个路由键,即便是空的,RabbitMQ也会将其和绑定使用的路由键进行匹配。如果相匹配,消息将会投递到该队列。如果不匹配,消息将会进入黑洞。通俗理解:队列绑定到交换器时有路由键,这个路邮件就相当于key-value中的key,value是队列。当Publisher发送消息时一定会携带路由键,有了路由键就让交换器知道了这个消息要发送给哪个队列。 |
8.Connection |
链接。指rabbit服务器和服务建立的TCP链接。 |
9.Channel |
信道。1,Channel中文叫做信道,是TCP里面的虚拟链接。例如:电缆相当于TCP,信道是一个独立光纤束,一条TCP连接上创建多条信道是没有问题的。2,TCP一旦打开,就会创建AMQP信道。3,无论是发布消息、接收消息、订阅队列,这些动作都是通过信道完成的。 |
10.Virtual Host |
虚拟主机。表示一批交换器,消息队列和相关对象。虚拟主机是共享相同的身份认证和加密环境的独立服务器域。每个vhost本质上就是一个mini版的RabbitMQ服务器,拥有自己的队列、交换器、绑定和权限机制。vhost是AMQP概念的基础,必须在链接时指定,RabbitMQ默认的vhost是****/****通俗理解:一个RabbitMQ可以包含多个虚拟主机,每个虚拟主机都是一个RabbitMQ。平时我们没有去创建虚拟主机,都是使用RabbitMQ里面为/虚拟主机,单实际上一个RabbitMQ可以包含多个虚拟主机主机的,也就是说一个RabbitMQ可以包含多个实例。就像MySQL可以创建多个数据库一样。 |
11.Borker |
表示消息队列服务器实体。 |
交换器和队列的关系 |
交换器是通过路由键和队列绑定在一起的,如果消息拥有的路由键跟交换器中绑定的路由键匹配,那么消息就会被路由到该绑定的队列中。 也就是说,消息到队列的过程中,消息首先会经过交换器,接下来交换器在通过路由键匹配分发消息到具体的队列中。 路由键可以理解为匹配的规则。 |
RabbitMQ为什么需要信道?为什么不是TCP直接通信? |
1. TCP的创建和销毁开销特别大。创建需要3次握手,销毁需要4次分手。2. 如果不用信道,那应用程序就会以TCP链接Rabbit,高峰时每秒成千上万条链接会造成资源巨大的浪费,而且操作系统每秒处理TCP链接数也是有限制的,必定造成性能瓶颈。3. 信道的原理是一条线程一条通道,多条线程多条通道同用一条TCP链接。一条TCP链接可以容纳无限的信道,即使每秒成千上万的请求也不会成为性能的瓶颈。 这也是为什么使用RabbitMQ去处理秒杀、流量削锋、海量请求时依然对RabbitMQ比较有信心的原因。 |
Redis
一、概述
(一)什么是Redis
Redis(Remote Dictionary Server) 是一个使用 C 语言编写的,开源的(BSD许可)高性能非关系型(NoSQL)的键值对数据库。
Redis 可以存储键和五种不同类型的值之间的映射。键的类型只能为字符串,值支持五种数据类型:字符串、列表、集合、散列表、有序集合。
与传统数据库不同的是 Redis 的数据是存在内存中的,所以读写速度非常快,因此 redis 被广泛应用于缓存方向,每秒可以处理超过 10万次读写操作,是已知性能最快的Key-Value DB。
另外,Redis 也经常用来做分布式锁。除此之外,Redis 支持事务 、持久化、LUA脚本、LRU驱动事件、多种集群方案。
(二)Redis优缺点
优点
- 读写性能优异, Redis能读的速度是110000次/s,写的速度是81000次/s。
- 支持数据持久化,支持AOF和RDB两种持久化方式。
- 支持事务,Redis的所有操作都是原子性的,同时Redis还支持对几个操作合并后的原子性执行。
- 数据结构丰富,除了支持string类型的value外还支持hash、set、zset、list等数据结构。
- 支持主从复制,主机会自动将数据同步到从机,可以进行读写分离。
缺点
- 数据库容量受到物理内存的限制,不能用作海量数据的高性能读写,因此Redis适合的场景主要局限在较小数据量的高性能操作和运算上。
- Redis 不具备自动容错和恢复功能,主机从机的宕机都会导致前端部分读写请求失败,需要等待机器重启或者手动切换前端的IP才能恢复。
- 主机宕机,宕机前有部分数据未能及时同步到从机,切换IP后还会引入数据不一致的问题,降低了系统的可用性。
- Redis 较难支持在线扩容,在集群容量达到上限时在线扩容会变得很复杂。为避免这一问题,运维人员在系统上线时必须确保有足够的空间,这对资源造成了很大的浪费。
(三)为什么要用 Redis /为什么要用缓存
主要从“高性能”和“高并发”这两点来看待这个问题。
高性能:
假如用户第一次访问数据库中的某些数据。这个过程会比较慢,因为是从硬盘上读取的。将该用户访问的数据存在数缓存中,这样下一次再访问这些数据的时候就可以直接从缓存中获取了。
操作缓存就是直接操作内存,所以速度相当快。如果数据库中的对应数据改变的之后,同步改变缓存中相应的数据即可!
高并发:
直接操作缓存能够承受的请求是远远大于直接访问数据库的,所以我们可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。
二、数据类型
数据类型 | 可以存储的值 | 操作 | 应用场景 |
---|---|---|---|
String | 字符串、整数或者浮点数 | 对整个字符串或者字符串的其中一部分执行操作 对整数和浮点数执行自增或者自减操作 | 做简单的键值对缓存 |
List | 列表 | 从两端压入或者弹出元素 对单个或者多个元素进行修剪, 只保留一个范围内的元素 | 存储一些列表型的数据结构,类似粉丝列表、文章的评论列表之类的数据 |
Set | 无序集合 | 添加、获取、移除单个元素 检查一个元素是否存在于集合中 计算交集、并集、差集 从集合里面随机获取元素 | 交集、并集、差集的操作,比如交集,可以把两个人的粉丝列表整一个交集 |
Hash | 包含键值对的无序散列表 | 添加、获取、移除单个键值对 获取所有键值对 检查某个键是否存在 | 结构化的数据,比如一个对象 |
ZSET | 有序集合 | 添加、获取、删除元素 根据分值范围或者成员来获取元素 计算一个键的排名 | 去重但可以排序,如获取排名前几名的用户 |
三、Redis的应用场景
计数器
可以对 String 进行自增自减运算,从而实现计数器功能。Redis 这种内存型数据库的读写性能非常高,很适合存储频繁读写的计数量。
缓存
将热点数据放到内存中,设置内存的最大使用量以及淘汰策略来保证缓存的命中率。
会话缓存
可以使用 Redis 来统一存储多台应用服务器的会话信息。当应用服务器不再存储用户的会话信息,也就不再具有状态,一个用户可以请求任意一个应用服务器,从而更容易实现高可用性以及可伸缩性。
全页缓存(FPC)
除基本的会话token之外,Redis还提供很简便的FPC平台。以Magento为例,Magento提供一个插件来使用Redis作为全页缓存后端。此外,对WordPress的用户来说,Pantheon有一个非常好的插件 wp-redis,这个插件能帮助你以最快速度加载你曾浏览过的页面。
查找表
例如 DNS 记录就很适合使用 Redis 进行存储。查找表和缓存类似,也是利用了 Redis 快速的查找特性。但是查找表的内容不能失效,而缓存的内容可以失效,因为缓存不作为可靠的数据来源。
消息队列(发布/订阅功能)
List 是一个双向链表,可以通过 lpush 和 rpop 写入和读取消息。不过最好使用 Kafka、RabbitMQ 等消息中间件。
分布式锁实现
在分布式场景下,无法使用单机环境下的锁来对多个节点上的进程进行同步。可以使用 Redis 自带的 SETNX 命令实现分布式锁,除此之外,还可以使用官方提供的 RedLock 分布式锁实现。
其它
Set 可以实现交集、并集等操作,从而实现共同好友等功能。ZSet 可以实现有序性操作,从而实现排行榜等功能。
四、持久化
(一)什么是持久化
持久化就是把内存的数据写到磁盘中去,防止服务宕机了内存数据丢失。
(二)Redis 的持久化机制是什么?各自的优缺点?
Redis 提供两种持久化机制 RDB(默认) 和 AOF 机制:
1.RDB
1.RDB:是Redis DataBase缩写快照
RDB是Redis默认的持久化方式。按照一定的时间将内存的数据以快照的形式保存到硬盘中,对应产生的数据文件为dump.rdb。通过配置文件中的save参数来定义快照的周期。
RDB持久化可以手动执行也可以根据配置定期执行,它的作用是将某个时间点上的数据库状态保存到RDB文件中,RDB文件是一个压缩的二进制文件,通过它可以还原某个时刻数据库的状态。由于RDB文件是保存在硬盘上的,所以即使redis崩溃或者退出,只要RDB文件存在,就可以用它来恢复还原数据库的状态。
可以通过SAVE或者BGSAVE来生成RDB文件。
SAVE命令会阻塞redis进程,直到RDB文件生成完毕,在进程阻塞期间,redis不能处理任何命令请求,这显然是不合适的。
BGSAVE则是会fork出一个子进程,然后由子进程去负责生成RDB文件,父进程还可以继续处理命令请求,不会阻塞进程。
优点:
- 1、只有一个文件 dump.rdb,方便持久化。
- 2、容灾性好,一个文件可以保存到安全的磁盘。
- 3、性能最大化,fork 子进程来完成写操作,让主进程继续处理命令,所以是 IO 最大化。使用单独子进程来进行持久化,主进程不会进行任何 IO 操作,保证了 redis 的高性能
- 4.相对于数据集大时,比 AOF 的启动效率更高。
缺点:
数据安全性低。RDB 是间隔一段时间进行持久化,如果持久化之间 redis 发生故障,会发生数据丢失。所以这种方式更适合数据要求不严谨的时候。
2.AOF
AOF持久化(即Append Only File持久化),则是将Redis执行的每次写命令记录到单独的日志文件中,当重启Redis会重新将持久化的日志中文件恢复数据。
==当两种方式同时开启时,数据恢复Redis会优先选择AOF恢复。==
AOF和RDB不同,AOF是通过保存redis服务器所执行的写命令来记录数据库状态的。
AOF通过追加、写入、同步三个步骤来实现持久化机制。
1.当AOF持久化处于激活状态,服务器执行完写命令之后,写命令将会被追加append到aof_buf缓冲区的末尾
2.在服务器每结束一个事件循环之前,将会调用flushAppendOnlyFile函数决定是否要将aof_buf的内容保存到AOF文件中,可以通过配置appendfsync来决定。
always ##aof_buf内容写入并同步到AOF文件 everysec ##将aof_buf中内容写入到AOF文件,如果上次同步AOF文件时间距离现在超过1秒,则再次对AOF文件进行同步 no ##将aof_buf内容写入AOF文件,但是并不对AOF文件进行同步,同步时间由操作系统决定
如果不设置,默认选项将会是everysec,因为always来说虽然最安全(只会丢失一次事件循环的写命令),但是性能较差,而everysec模式只不过会可能丢失1秒钟的数据,而no模式的效率和everysec相仿,但是会丢失上次同步AOF文件之后的所有写命令数据。
优点:
- 1、数据安全,aof 持久化可以配置 appendfsync 属性,有 always,每进行一次 命令操作就记录到 aof 文件中一次。
- 2、通过 append 模式写文件,即使中途服务器宕机,可以通过 redis-check-aof 工具解决数据一致性问题。
- 3、AOF 机制的 rewrite 模式。AOF 文件没被 rewrite 之前(文件过大时会对命令 进行合并重写),可以删除其中的某些命令(比如误操作的 flushall))
缺点:
- 1、AOF 文件比 RDB 文件大,且恢复速度慢。
- 2、数据集大的时候,比 rdb 启动效率低。
优缺点是什么?
- AOF文件比RDB更新频率高,优先使用AOF还原数据。
- AOF比RDB更安全也更大
- RDB性能比AOF好
- 如果两个都配了优先加载AOF
3.如何选择合适的持久化方式
一般来说, 如果想达到足以媲美PostgreSQL的数据安全性,你应该同时使用两种持久化功能。在这种情况下,当 Redis 重启的时候会优先载入AOF文件来恢复原始的数据,因为在通常情况下AOF文件保存的数据集要比RDB文件保存的数据集要完整。
如果你非常关心你的数据, 但仍然可以承受数分钟以内的数据丢失,那么你可以只使用RDB持久化。
有很多用户都只使用AOF持久化,但并不推荐这种方式,因为定时生成RDB快照(snapshot)非常便于进行数据库备份, 并且 RDB 恢复数据集的速度也要比AOF恢复的速度要快,除此之外,使用RDB还可以避免AOF程序的bug。
如果你只希望你的数据在服务器运行的时候存在,你也可以不使用任何持久化方式。
五、过期键的删除策略
Redis的过期键的删除策略
我们都知道,Redis是key-value数据库,我们可以设置Redis中缓存的key的过期时间。Redis的过期策略就是指当Redis中缓存的key过期了,Redis如何处理。
过期策略通常有以下三种:
定时过期:每个设置过期时间的key都需要创建一个定时器,到过期时间就会立即清除。该策略可以立即清除过期的数据,对内存很友好;但是会占用大量的CPU资源去处理过期的数据,从而影响缓存的响应时间和吞吐量。
惰性过期:只有当访问一个key时,才会判断该key是否已过期,过期则清除。该策略可以最大化地节省CPU资源,却对内存非常不友好。极端情况可能出现大量的过期key没有再次被访问,从而不会被清除,占用大量内存。
定期过期:每隔一定的时间,会扫描一定数量的数据库的expires字典中一定数量的key,并清除其中已过期的key。该策略是前两者的一个折中方案。通过调整定时扫描的时间间隔和每次扫描的限定耗时,可以在不同情况下使得CPU和内存资源达到最优的平衡效果。 (expires字典会保存所有设置了过期时间的key的过期时间数据,其中,key是指向键空间中的某个键的指针,value是该键的毫秒精度的UNIX时间戳表示的过期时间。键空间是指该Redis集群中保存的所有键。)
==Redis中同时使用了惰性过期和定期过期两种过期策略==。
六、Redis的内存淘汰策略
假设redis每次定期随机查询key的时候没有删掉,这些key也没有做查询的话,就会导致这些key一直保存在redis里面无法被删除,这时候就会走到redis的内存淘汰机制。
全局的键空间选择性移除
- noeviction:当内存不足以容纳新写入数据时,新写入操作会报错。
- allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的key。(这个是最常用的)
- allkeys-random:当内存不足以容纳新写入数据时,在键空间中,随机移除某个key。
设置过期时间的键空间选择性移除
- volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的key。
- volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个key。
- volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的key优先移除。
总结
Redis的内存淘汰策略的选取并不会影响过期的key的处理。内存淘汰策略用于处理内存不足时的需要申请额外空间的数据;过期策略用于处理过期的缓存数据。
七、缓存异常
(一)缓存雪崩
缓存雪崩是指缓存同一时间大面积的失效,所以,后面的请求都会落到数据库上,造成数据库短时间内承受大量请求而崩掉。雪崩和击穿、热key的问题不太一样的是,它是指大规模的缓存都过期失效了。
解决方案
- 缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。
- 一般并发量不是特别多的时候,使用最多的解决方案是加锁排队。
- 给每一个缓存数据增加相应的缓存标记,记录缓存的是否失效,如果缓存标记失效,则更新数据缓存。
(二)缓存穿透
缓存穿透是指缓存和数据库中都没有的数据,导致所有的请求都落到数据库上,造成数据库短时间内承受大量请求而崩掉。
解决方案
接口层增加校验,如用户鉴权校验,id做基础校验,id<=0的直接拦截;
从缓存取不到的数据,在数据库中也没有取到,这时也可以将key-value对写为key-null,缓存有效时间可以设置短点,如30秒(设置太长会导致正常情况也没法使用)。这样可以防止攻击用户反复用同一个id暴力攻击
采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的 bitmap 中,一个一定不存在的数据会被这个 bitmap 拦截掉,从而避免了对底层存储系统的查询压力
布隆过滤器的原理是在你存入数据的时候,会通过散列函数将它映射为一个位数组中的K个点,同时把他们置为1。这样当用户再次来查询A,而A在布隆过滤器值为0,直接返回,就不会产生击穿请求打到DB了。
显然,使用布隆过滤器之后会有一个问题就是误判,因为它本身是一个数组,可能会有多个值落到同一个位置,那么理论上来说只要我们的数组长度够长,误判的概率就会越低,这种问题就根据实际情况来就好了。
附加:
对于空间的利用到达了一种极致,那就是Bitmap和布隆过滤器(Bloom Filter)。
Bitmap: 典型的就是哈希表
缺点是,Bitmap对于每个元素只能记录1bit信息,如果还想完成额外的功能,恐怕只能靠牺牲更多的空间、时间来完成了。
布隆过滤器(推荐)
就是引入了k(k>1)k(k>1)个相互独立的哈希函数,保证在给定的空间、误判率下,完成元素判重的过程。
它的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率和删除困难。
Bloom-Filter算法的核心思想就是利用多个不同的Hash函数来解决“冲突”。
Hash存在一个冲突(碰撞)的问题,用同一个Hash得到的两个URL的值有可能相同。为了减少冲突,我们可以多引入几个Hash,如果通过其中的一个Hash值我们得出某元素不在集合中,那么该元素肯定不在集合中。只有在所有的Hash函数告诉我们该元素在集合中时,才能确定该元素存在于集合中。这便是Bloom-Filter的基本思想。
Bloom-Filter一般用于在大数据量的集合中判定某元素是否存在。
(三)缓存击穿
缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。和缓存雪崩不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。
解决方案
- 设置热点数据永远不过期。
- 加互斥锁,互斥锁
(四)缓存预热
缓存预热就是系统上线后,将相关的缓存数据直接加载到缓存系统。这样就可以避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题!用户直接查询事先被预热的缓存数据!
解决方案
- 直接写个缓存刷新页面,上线时手工操作一下;
- 数据量不大,可以在项目启动的时候自动进行加载;
- 定时刷新缓存;
(五)缓存降级
当访问量剧增、服务出现问题(如响应时间慢或不响应)或非核心服务影响到核心流程的性能时,仍然需要保证服务还是可用的,即使是有损服务。系统可以根据一些关键数据进行自动降级,也可以配置开关实现人工降级。
缓存降级的最终目的是保证核心服务可用,即使是有损的。而且有些服务是无法降级的(如加入购物车、结算)。
在进行降级之前要对系统进行梳理,看看系统是不是可以丢卒保帅;从而梳理出哪些必须誓死保护,哪些可降级;比如可以参考日志级别设置预案:
- 一般:比如有些服务偶尔因为网络抖动或者服务正在上线而超时,可以自动降级;
- 警告:有些服务在一段时间内成功率有波动(如在95~100%之间),可以自动降级或人工降级,并发送告警;
- 错误:比如可用率低于90%,或者数据库连接池被打爆了,或者访问量突然猛增到系统能承受的最大阀值,此时可以根据情况自动降级或者人工降级;
- 严重错误:比如因为特殊原因数据错误了,此时需要紧急人工降级。
服务降级的目的,是为了防止Redis服务故障,导致数据库跟着一起发生雪崩问题。因此,对于不重要的缓存数据,可以采取服务降级策略,例如一个比较常见的做法就是,Redis出现问题,不去数据库查询,而是直接返回默认值给用户。
(六)热点数据和冷数据
热点数据,缓存才有价值
对于冷数据而言,大部分数据可能还没有再次访问到就已经被挤出内存,不仅占用内存,而且价值不大。频繁修改的数据,看情况考虑使用缓存
对于热点数据,比如我们的某IM产品,生日祝福模块,当天的寿星列表,缓存以后可能读取数十万次。再举个例子,某导航产品,我们将导航信息,缓存以后可能读取数百万次。
数据更新前至少读取两次,缓存才有意义。这个是最基本的策略,如果缓存还没有起作用就失效了,那就没有太大价值了。
那存不存在,修改频率很高,但是又不得不考虑缓存的场景呢?有!比如,这个读取接口对数据库的压力很大,但是又是热点数据,这个时候就需要考虑通过缓存手段,减少数据库的压力,比如我们的某助手产品的,点赞数,收藏数,分享数等是非常典型的热点数据,但是又不断变化,此时就需要将数据同步保存到Redis缓存,减少数据库压力。
(七)缓存热点key
缓存中的一个Key(比如一个促销商品),在某个时间点过期的时候,恰好在这个时间点对这个Key有大量的并发请求过来,这些请求发现缓存过期一般都会从后端DB加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端DB压垮。
解决方案
1.对缓存查询加锁,如果KEY不存在,就加锁,然后查DB入缓存,然后解锁;其他进程如果发现有锁就等待,然后等解锁后返回数据或者进入DB查询
2.提前把热key打散到不同的服务器,降低压力
八、实现Redis的高可用
要想实现高可用,一台机器肯定是不够的,而redis要保证高可用,有2个可选方案。
(一)主从架构
主从模式是最简单的实现高可用的方案,核心就是主从同步。主从同步的原理如下:
- slave发送sync命令到master
- master收到sync之后,执行bgsave,生成RDB全量文件
- master把slave的写命令记录到缓存
- bgsave执行完毕之后,发送RDB文件到slave,slave执行
- master发送缓存中的写命令到slave,slave执行
这里我写的这个命令是sync,但是在redis2.8版本之后已经使用psync来替代sync了,原因是sync命令非常消耗系统资源,而psync的效率更高。
(二)哨兵
基于主从方案的缺点还是很明显的,假设master宕机,那么就不能写入数据,那么slave也就失去了作用,整个架构就不可用了,除非你手动切换,主要原因就是因为没有自动故障转移机制。而哨兵(sentinel)的功能比单纯的主从架构全面的多了,它具备自动故障转移、集群监控、消息通知等功能。
哨兵可以同时监视多个主从服务器,并且在被监视的master下线时,自动将某个slave提升为master,然后由新的master继续接收命令。整个过程如下:
- 初始化sentinel,将普通的redis代码替换成sentinel专用代码
- 初始化masters字典和服务器信息,服务器信息主要保存ip:port,并记录实例的地址和ID
- 创建和master的两个连接,命令连接和订阅连接,并且订阅sentinel:hello频道
- 每隔10秒向master发送info命令,获取master和它下面所有slave的当前信息
- 当发现master有新的slave之后,sentinel和新的slave同样建立两个连接,同时每个10秒发送info命令,更新master信息
- sentinel每隔1秒向所有服务器发送ping命令,如果某台服务器在配置的响应时间内连续返回无效回复,将会被标记为下线状态
- 选举出领头sentinel,领头sentinel需要半数以上的sentinel同意
- 领头sentinel从已下线的的master所有slave中挑选一个,将其转换为master
- 让所有的slave改为从新的master复制数据
- 将原来的master设置为新的master的从服务器,当原来master重新回复连接时,就变成了新master的从服务器
sentinel会每隔1秒向所有实例(包括主从服务器和其他sentinel)发送ping命令,并且根据回复判断是否已经下线,这种方式叫做主观下线。当判断为主观下线时,就会向其他监视的sentinel询问,如果超过半数的投票认为已经是下线状态,则会标记为客观下线状态,同时触发故障转移。
九、Redis集群原理
如果说依靠哨兵可以实现redis的高可用,如果还想在支持高并发同时容纳海量的数据,那就需要redis集群。redis集群是redis提供的分布式数据存储方案,集群通过数据分片sharding来进行数据的共享,同时提供复制和故障转移的功能。
(一)节点
一个redis集群由多个节点node组成,而多个node之间通过cluster meet命令来进行连接,节点的握手过程:
- 节点A收到客户端的cluster meet命令
- A根据收到的IP地址和端口号,向B发送一条meet消息
- 节点B收到meet消息返回pong
- A知道B收到了meet消息,返回一条ping消息,握手成功
- 最后,节点A将会通过gossip协议把节点B的信息传播给集群中的其他节点,其他节点也将和B进行握手
(二)槽slot
redis通过集群分片的形式来保存数据,整个集群数据库被分为16384个slot,集群中的每个节点可以处理0-16384个slot,当数据库16384个slot都有节点在处理时,集群处于上线状态,反之只要有一个slot没有得到处理都会处理下线状态。通过cluster addslots命令可以将slot指派给对应节点处理。
slot是一个位数组,数组的长度是16384/8=2048,而数组的每一位用1表示被节点处理,0表示不处理,如图所示的话表示A节点处理0-7的slot。
当客户端向节点发送命令,如果刚好找到slot属于当前节点,那么节点就执行命令,反之,则会返回一个MOVED命令到客户端指引客户端转向正确的节点。(MOVED过程是自动的)
如果增加或者移出节点,对于slot的重新分配也是非常方便的,redis提供了工具帮助实现slot的迁移,整个过程是完全在线的,不需要停止服务。
(三)故障转移
如果节点A向节点B发送ping消息,节点B没有在规定的时间内响应pong,那么节点A会标记节点B为pfail疑似下线状态,同时把B的状态通过消息的形式发送给其他节点,如果超过半数以上的节点都标记B为pfail状态,B就会被标记为fail下线状态,此时将会发生故障转移,优先从复制数据较多的从节点选择一个成为主节点,并且接管下线节点的slot,整个过程和哨兵非常类似,都是基于Raft协议做选举。