面试资料
面向对象是什么?面向对象的特征有什么?
面向对象比较抽象,结合面向过程,举例子说明。
封装、继承和多态。
-
封装
将对象的属性和行为抽取出来,对外暴露公共方法来操作对象,方法的具体实现细节在类内部,对外部隐藏。比如一个用于排序的工具类,它暴露出来的排序方法,在调用这个方法后完成排序,但是这个方法内到底用了什么排序算法以及是怎么实现的都是不知道的。
-
继承
当某一类对象存在相同的方法或者属性时,将这些相同的方法或属性提取出来,定义一个父。这些拥有相同方法或属性的类继承这个父类,就得到了父类中的属性和行为,对于父类中不满足子类特定需求的方法时,子类也可以重写该方法。
如果继承使用不恰当会造成方法污染和方法爆炸。
方法污染是指:有一个父类为鸟类,其中定义了飞行的方法,鸵鸟继承了这个鸟类,但是鸵鸟不会飞,那么飞的方法对于鸵鸟来说就是方法污染。(解决办法:飞的方法定义成接口、会飞的鸟类和不飞会的鸟类继承一个公共包类)。
方法爆炸是指:当继承树过多时,最底层类拥有上层类中所有的方法,导致方法过多,程序很难理解且不易维护,这就是方法爆炸。
继承的作用是:可以减少子类中重复的代码,方法变更只需要修改父类中的方法即可,对于不满足子类需求的方法在子类中可以重写,提高了程序的可维护性和可扩展性。 -
多态
多态是以其它特征为基础的,它是根据运行时实际的对象类型,父类指向子类的引用,调用相同方法,得到不同结果,表现出的不同形态,最能体现多态的地方就是方法的重写。
对于方法重写来说,重写保证是同一个方法,但是方法的实现是不同的,所以在运行时得到的结果也是不同的。
很多地方将方法的重载也作为多态的表现形式,但是在我看来,重载的方法之间,各个方法的定义都是不同的,如参数类型,那么它们就不是同一个方法,我认为这不是多态的表现。
JDK、JRE、JVM三者之间的关系
JDK JAVA开发工具,包含了JRE(JAVA运行环境),JRE中又包含了JVM。
JVM可以执行java源文件编译后的.class文件。
JRE是Java运行环境,它包含了JVM和JVM执行.class文件所需的类库。
JDK是java开发工具包,它提供开发Java时所需要的工具,其中JRE环境。
Integer
和int
类型的区别。
int
是基本类型,Integer
是int
类型的包装类型。
int
类型和Integer
直接比较(==)Integer
类型会调用intValue
方法返回后比较【拆箱】。
Integer i = 100
调用的是valueOf
方法,当值在-128到127之间时,会从缓存Integer
数组中取对应的值返回并赋值给i
,所以下面是成立的:
Integer i = 100;
Integer j = 100;
System.out.print(i == j); // true
Integer i1 = 128;
Integer i2 = 128;
System.out.print(i1 == i2); // false
== 和 equals
== 对于基本数据类型,比较的是变量的值,对于引用数据类型比较的是对象在堆中的地址值。
equals
只能用于引用数据类型,如果类没有重写equals
方法,则调用Object
类中的equals
方法,使用==比较;如果类中重写了equals
方法则用重写的equals
方法比较。
对于String类型来说,它重写了equals
方法,所以使用equals
方法可以用来比较字符串的值是否相同。
String str1 = new String("hello");
String str2 = str1;
System.out.println(str2 == str1); // true
final 的作用
修饰类,表示类不可以被继承。
修饰方法,表示方法不可以重写(可以重载)。
修饰变量,变量一但被赋值就不可更改【引用数据类型可以修改对象的值】。
修饰类变量:在静态代码块或声明时赋值
成员变量:在声明、初始化代码块或构造器中赋值
局部变量:在定义时赋值或者定义后在使用前赋值(只能赋值一次)
内部类或匿名内部类访问外部变量为什么加final
内部类和外部类都是同一级别的,它不会因为引用它的方法执行结束就被销毁了(如线程),按正常逻辑去理解,当方法结束执行变量被销毁后,但内部类还需要一个可访问的变量,这里就产生了一个矛盾。
为了解决这个矛盾,内部类在编译后生成一个成员变量,来保存访问的外部变量。使用final修饰是为了保证数据不被修改,保证数据一致性。
String、StringBuffer和StringBuilder区别及使用场景
String中是用char
数组来保存字符串的,并且用final
修饰的,所以它的值一但设定就不能改变,每次操作都会生产一个新的对象。
StringBuffer
和StringBuilder
都是继承自AbstractStringBuilder
,在AbstractStringBuilder
中定义了保存字符串的char
类型的数组,与String类型不同的是,这个数组是可变的。
StringBuffer
中操作字符串的方法上都加synchronized
对于多线程环境来说是线程安全的,StringBuilder
则是线程不安全的。
对于操作字符串效率来说,因为StringBuilder
操作字符串时没有加锁,所以StringBuilder
的效率是最高的,而String
每次操作都会产生新的对象,所以String
的效率是最低的。
重载和重写
重写:在父子类方法中,方法名、参数列表相同,返回值小于等于父类,抛出异常小于等于父类,访问修饰符大于等于父类,private
修饰的方法不能被重写。
重载:在同一个类中,方法名相同,参数类型、个数、顺序不同,返回值和访问修饰符可以不同,就是方法重载。
接口和抽象类
接口中的方法是public abstract
修饰的,没有方法体,JDK1.8过后,接口中可以使用default
关键字来定义有方法体的方法,接口中也可以有成员变量,是通过public static final
来修饰的。
接口是对行为的抽象,它能对类的行为进入约束,规定这个类必须拥有这个行为,但它不关注这个行为的具体实现。
抽象类本质上是一个类,只是不能够实例化。只有一个类中有一个未被实现的抽象方法,那么这个类就是抽象类。
抽象类是对某一类事物的抽象,它对某一类事物通用特性进行实现,并且对差异化特征进行约束,并交由子类实现。
接口和实现类相当于是一个like的关系,继承类和抽象类相当于是一个is的关系。
在代码设计如果关注类的本质是什么就用抽象类,如果只关注要类中有什操作就使用接口,并且还要注意在Java中接口是多实现的,而类是单继承的。
hashCode和equals
hashCode
是根据对象信息计算出来的一个整数值,可以确定对象在哈希表中的位置,可以减少调用equals方法比较对象是否相等的次数,提高检索对象的效率。
equals
是用来比较对象是否相等的,默认Object
类中的equals
方法是采用==来比较对象的。
如HashSet中就用到了hashCode
和equals
方法来查找和判断元素是否存在。
为什么不同的对象可能会输出相同的hashCode
因为Hash算法最终输出一个长度固定的散列值,由于长度固定,保存的数据是有限的,势必会出现相同的值,hashCode的长度越长出现重复的概率越低。
出现相同hashCode的情况也叫做哈希冲突,解决哈西冲突又有如下方法:
开放定址法:当出现哈希冲突时,通过其它算法【线性探测、二次探测、伪随机探测再散列】来获得对象在Hash表中新的位置,这种做法可能会造成占用原本正常属于改位置对象的位置。
再哈希法:出现相同哈希时对结果再次哈希,直到不重复为止,会增加计算量。
链地址法:将Hash值相同的元素用链表链接。
如果A、B有相同的Hash,A插入时到正确的位置,B再插入发现改位置有元素了,取这个元素出来通过equals比较不是同一个元素,此时就要解决hash冲突。
List和Set的区别
List是有序集合,可以保存多个null元素,它是按元素插入顺序进行排序的,它可以通过迭代器进行遍历,也可以通过索引方式获取集合中的元素。
Set是无序集合,只能保存一个null元素,它的插入是没有顺序的,它只能通过迭代器来遍历元素。
ArrayList和LinkedList区别
ArrayList是基于动态数组构建的集合,它的元素存储在一块连续的内存空间上,由于是基于数组实现的,所以它访问和修改较快,由于插入和删除时要移动后面的所有元素,所以插入和删除时较慢。
ArrayList默认数组长度为10,在未指定数组长度时第一次插入元素会初始化数组长度,当插入元素超过数组长度时数组会扩容为原来的1.5倍(创建新数组复制元素),在创建ArrayList时可以指定合适的长度,减少扩容次数。
LinkedList是基于双向链表实现的,它的元素分散在内存中,由于是基于双向链表实现的,所以它适合经常删除和插入的场景,不适合查询较多的场景。对于Linked的遍历要使用迭代器,如果使用for每一次迭代都会遍历集合直到找到对应的元素,性能较低【通过get(
index)】。
对于只涉及尾插元素时,使用ArrayList的效率要高于LinkedList,因为LinkedList要创建大量的Node,而ArrayList尾插时不需要复制元素,且查询、访问速度要优于LinkedList。
HashTable 和 HashMap
HashTable
的方法上有synchronized
修饰,是线程安全的,HashMap
是线程不安全的。
HashTable
的key和value都不能为null,HashMap
的key和value都可以为null。
HashTable
和HashMap
的Iterator
迭代器是fail-fast的,在迭代器创建后添加值,那么迭代期间会抛出ConcurrentModifyException
。HashTable
还提供了一个Enumeration
迭代器,它是fail-safe的。
Enumeration<Object> keys = hashTable.keys();
while (keys.hasMoreElements()) {
hashTable.put("e", "E");
System.out.println(keys.nextElement());
}
HashMap 底层原理
HashMap
底层是数组加链表实现的。当数组长度大于64且链表高度到8时,链表将转换成红黑树,元素个数低于6时,红黑树将转换为链表。【6和8中间有个7是用来作缓冲,避免频繁的切换数据结构】
HashMap
执行过程大致是:
- 先计算key的Hash值,再次Hash然后对数组长度取模,得到对应的数组下标。
- 如果没有产生Hash冲突,元素存入数组中。
- 如果产生Hash冲突,通过equals方法比较元素是否相同,如果元素相同,则更新元素,如果元素不同则以链表或者红黑树方式存储元素。JDK1.8后链表采用尾插法。
- 当key为
null
时,下标为0。
扩容机制:HashMap默认长度为16,加载因子为0.75,当添加的元素超过长度*加载因子的时候,数组会扩容为原来的两倍。
为什么树化条件为8
作者在大量实验后发现发生8次哈希碰撞的概率已经非常低了(约千万分之6),几乎不可能发生,如果真的发生8次哈希碰撞了,说明用户设计hash函数可能存在问题,
后续还有可能发生哈希碰撞,这个时候应该用红黑树来提高查询效率。
为什么一开始就不用红黑树呢?
红黑树的时间复杂度比链表要小,当数据量小的时候,体现不明显,所以用链表的红黑树都可以,但是红黑树的空间占用比链表高,所以优先使用链表。
ConcurrentHashMap
实现原理
在JDK7ConcurrentHashMap
采用分段锁机制,锁使用的是ReentrantLock
,把Hash数组分成了多个小数组Segment
,每个Segment
分配一把锁,当一个线程占用锁访问某个Segment
时,其它Segment
也能被其它线程访问,以此来实现并发访问。
在JDK8中使用HashMap
相同的数据结构,采CAS(Compare and Swap)加sychronized
对哈希数组元素头节点加锁,实现更细粒度的锁控制,某一个节点加锁不影响其它节点,并发性能更好。
在操作元素中的数据时,首先给元素的头结点加锁,然后通过CAS判断值是否符合预期,如果符合才操作数据。
get方法需要加锁吗?为什么?
Node
数组和HashEntry
中的value
及next
都采用volatile
修饰,保证了多线程下数据获取时的可见性。所以获取时不需要加锁。
不支持key或value为null的原因是什么?
key
能不能为null
对ConcurrentHashMap
并没有什么影响,实际上代码里限制了Key不能为null,至于为什么不能为null,要看作者是怎么想的了。
多线程环境下如果value
为空不能清楚的知道到底是值不存在还是值本身就为null
,containsKey()
是通过get(key) != null
来判断是否包含某个值的。
迭代器是强一致性的还是弱一致性的?
ConcurrentHashMap
的迭代器是弱一致性的,在迭代器创建后,元素发生了变化,如果变化发生在已遍历过的部分,迭代器不会做出反应,如果变化发生在未遍历过的部分,迭代器会做出反应。
JDK7和JDK8的区别?
- 底层数据结构不同。查询时间复杂度不同。
- JDK遍历链表的时间复杂度是O(n),JDK遍历红黑树的时间复杂度是O(logN)。
- JDK7采用分段锁机制,JDK8采用CAS加synchronized来保证线程安全。
- 锁粒度不同:JDK7锁的是segment,JDK8锁的是链表中的头结点或者红黑树的根节点。
JDK8中为什么使用synchronized替换ReentrantLock?
synchronized
在JDK1.6中优化提升了锁性能。synchronized
更适合细粒度的控制,如果对Segment
采用更细粒度的控制,会造成巨大的内存浪费,因为Segment
继承自ReentrantLock
。
synchronized 在 JDK1.6之前一直都是重量级锁,它是向操作系统申请锁资源,借助操作系统的互斥锁(Mutex Lock)来实现线程同步。
synchronized 在 JDK1.6之后做了优化:
锁升级过程: 无锁 —> 偏向锁 —> 轻量级锁 —> 重量级锁。
- 偏向锁:在大多数情况下,锁不存在多线程竞争,而是由同一个线程多次获得,所以引入了偏向锁,在锁对象的对象头中记录当前获取到该锁的线程ID,该线程下次可以直接获取。
- 轻量级锁:当出现锁竞争,此时锁会升级为轻量级锁。它采用CAS也就是自旋来尝试获取锁,自旋线程不会阻塞会占用CPU资源,所以它根据自旋次数和成功率来决定何时将自旋锁升级为重量级锁。
- 重量级锁:当出现大量线程竞争锁资源时,轻量级锁会导致大量线程占用过多CPU资源,此时会使用重量级锁,产生锁竞争的线程会进入等待队列,释放CPU资源。
并发度怎么设计的?
并发度:不引起锁竞争的最大线程数。
JDK7并发度依赖Segment
数量,可在构造函数中设计;JDK8并发度为Node数组长度。
和HashTable的效率哪个高?为什么?
HashTable
是对整个哈希表加锁,效率低。
多线程下安全操作Map还有其它方法吗?
Collections.synchronizedMap(Map map)
,是对传入的map
进行了一次封装,采用synchronized对整个数据加锁,效率也不高。
如何实现IOC容器
简单回答:配置扫描的包扫描路径,获取包下所有.class文件,通过反射创建需要给IOC容器管理的类的实例,保存在一个Map中。
什么是字节码?采用字节码的好处是什么?
Java源文件被编译成.class文件,也就是字节码,字节码交由JVM解释器,解释器将字节码翻译成特定平台的机器码再执行。
字节码的好处:只要能解释字节码的机器都能执行该程序,所以有比较好的可移植性,它面向JVM,只要机器上能运行JVM就能执行该程序。
Java类加载器
JDK自带有三个类加载器:
- BootstrapClassLoader:用于加载jre/lib目录下的jar包和.class文件。
- ExtClassLoader:加载jre/lib/ext目录下的jar包
- AppClassLoader:加载当前项目classpath下的类文件。
其中ExtClassLoader是AppClassLoader的父加载器(不是父类),BootstrapClassLoader是ExtClassLoader父加载器。
自定义加载器要继承ClassLoader
。AppClassLoader是自定义加载器的父加载器。
Class<?> obj3 = Class.forName("java.lang.Object");
Constructor<?> constructor = obj3.getConstructor();
Object o = constructor.newInstance();
双亲委派模型
加载一个类时,类加载器首先是去上级类加载器的缓存中查询是否加载过该类,直到查询至顶级类加载器,如果没有找到该类,就会从顶级类加载器开始,
向下查找当前类加载器加载路径是否有该类,直到查找至发起加载的加载器为止,如果最终都没找到该类抛出ClassNotFoundException
。
好处:
- 提升安全性,避免用户自己编写的类替换替换Java核心类,如自己写一个
java.lang.String
类就不会被加载。 - 同一个类由同一个加载器加载,避免类重复加载。
打破双亲委派机制
自定义ClassLoader
,重写loadClass
方法,不按照双亲委派机制向上查找就算打破了双亲委派机制。
如Tomcat打破了双亲委派机制,Tomcat为每个web应用创建了一个WebAppClassLoader,加载Web应用独享的类,那么就做到了Web应用层级的隔离。
如果Web应用间有共享的类,就交给WebAppClassLoader的父加载器ShareClassLoader加载,实现Web应用间类共享。
为了隔绝Tomcat本身和应用程序的类,又通过CatalinaClassLoader加载Tomcat本身的依赖。对于Tomcat和Web应用间共享的类,又通过CommonClassLoader加载。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-32qm93Ge-1654143557676)(img.png)]
Java 异常体系
Java中的异常都继承自Throwable
类,Throwable
类下有Error
和Exception
两个子类。
发Error
会导致程序被迫停止运行(OOM),发生Exception
会导致当前线程结束运行(NPE)。
Exception
类分为RuntimeException
和CheckedException
,RuntimeException
发生在程序运行期间,CheckedException
发生在程序编译过程中,会导致程序编译不通过。
GC如何判断对象可以被回收
GC判断可以通过引用计数法或可达性分析法来判断对象是否可以被回收。
- 引用计数法:每个对象都有一个引用计数属性,新增一个引用时,引用计数属性加1,引用释放时,计数属性减1,当计数属性为0时,对象可以被回收。如果出现A引用B,B引用A的情况,可能导致对象永远不能被回收。
- 可达性分析法:从GC Roots开始向下搜索,搜索走过的路径为引用链,当一个对象没有与任何引用链关联时,那么这个对象就是可以被回收的。
在Java中使用的是可达性分析法来判断对象是否可以被回收。
可作为GC Roots的对象可以是:
- 虚拟机栈(栈桢中的本地变量变)中引用的对象
- 方法区中静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI(Java Native Interface,Native方法)引用的对象
当对象通过可达性分析后,发现对象不可达,对象并不会立即被回收。判断对象是否可以被回收至少要经理两次标记,第一次是经过可达性分析标记不可达对象,第二次
是判读是否要执行对象是否覆盖了finalize
方法,如果没覆盖对象被回收,如果覆盖了会将这个对象放入队列中执行finalize
方法,finalize
方法执行完毕后
再次判断对象是否可达,如果不可达就回收,否则对象"复活"(finalize中引用了其它活跃对象)。
线程生命周期
在Thread类中定义了这样几个状态:NEW、RUNNABLE、BLOCKED【等待锁时】、WAITING、TIME_WAITING 和 TERMINATED几个状态。
线程生命周期分为创建、就绪、运行、阻塞和死亡状态。
sleep 和 wait
sleep
是Thread
中的方法,wait
是Object
中的方法。sleep
不会释放锁,wait
方法会释放锁。sleep
不依赖synchronized
,wait
依赖synchronized
,wait
方法必须由monitor关联的对象调用。sleep
时间到后退出阻塞状态,不带时间参数的wait
方法需要被唤醒才能退出阻塞状态。
线程安全
多个线程获取或操作共享变量都能得到正确的结果,那么就说这个变量是线程安全的。
堆是进程内线程共享的,使用时需要分配空间,使用完毕后要释放空间;栈是线程独有的,不需要显示分配和释放。
守护线程
java中有两种线程,用户线程和守护线程,当程序中没一个用户线程后,程序就退出了,守护线程终止执行。
因为它的退出时间不可控,所以不要在守护线程中作IO、文件等资源操作,否则会导致资源无法正常释放。
GC垃圾回收线程就是守护线程,没有正在执行的用户线程,就不会产生"垃圾",所以GC垃圾回收线程可以设置成守护线程。
ThreadLocal
每个Thread
对象中均包含一个ThreadLocalMap
变量threadLocals,它存储了本线程中所有ThreadLocal
对象及其对应的值。
ThradLocalMap
里面是一个Entry
数组,它的key就是ThreadLocal
对象,value就是设置的值,并且它的key是一个弱引用对象,如果没有指向这个的强引用,
key就会被垃圾回收器回收,如果线程一直没有结束,就会导致value无法被回收,造成内存泄露。
在执行set
或者get
方法时,首先获取的是当前Thread
对象,然后通过当前Thread
对象的threadLocals
来设置或获取值。
因为第个Thread
对象都有一个ThreadLocalMap
变量,所以不会存在线程安全问题。
使用场景:
- 对象传递层次较深时,使用
ThreadLocal
来避免多次传递。 - 线程间数据隔离,如在多线程环境中使用
SimpleDateFormat
对时间进行格式化时。 - 进行事务操作,保存connection信息。
ThreadLocal 内存泄露及解决办法
不再会被使用的对象或者变量占用的内存不能被回收,就是内存泄露,当堆积大量的未被回收的对象时,可能会导致内存溢出。
ThreadLocalMap
中的Entry
的key是弱引用的ThreadLocal
,当这个key没有被强引用时,就会被回收,早成Entry
中关联关系为null
关联一个value。
由于线程中的ThreadLocalMap
是跟随线程的生命周期的,导致线程未结束时value
无法被回收,产生内存泄露。 尤其在项目中使用线程池时,任务结束后,线程可能并不会被销毁,就会导致value一直无法被回收。
解决办法:
- 在每次使用完后调用
ThreadLocal
的remove
方法清除数据。 - 把
ThreadLocal
设置为static final
,保证ThreadLocal
为强引用,保证任何时候都能通过ThreadLocal
获取到设置的值。 - 调用
set
、get
时会清除key为null的值。
弱引用、强引用、软引用和虚拟引用的区别
- 强引用:一般指通过
new
方式创建的对象,只要强引用存在,对象就不会被垃圾回收器回收,即使是内存不足,抛出OOM导致程序终止。 - 软引用(SoftReference):在系统将要发生内存溢出前,会将软引用对象列为回收范围进行回收,如果回收后还是没有足够的内存,才会抛出OOM。
// obj 在这里是强引用
Object obj = new Object();
// sor 在这里是强引用,但是 obj 在这里已经变成软引用
SoftReference<Object> sor = new SoftReference<>(obj);
if (sor.get() != null) {
// 内存充足,obj未被回收
} else {
// 强引用
obj = new Object();
// 变成软引用
sor = new SoftReference<>(obj);
}
-
弱引用(WeakReference):弱引用对象只要被强引用关联就会被回收。
它和强引用的区别是:如果一个List中保存了一个强引用Obj,如果这个Obj被设置为null,这个Obj也不会被回收;如果这个Obj是弱引用,当Obj的值为null是就会被垃圾回收器回收(
不是立即回收,GC线程运行优先级低于用户线程,弱引用对象会在内存中存在一定时间)。
虚引用(PhantomReference):虚引用作用在于跟踪垃圾回收过程,一个对象仅持有虚引用时,就会在垃圾回收后将这个虚引用加入虚引用队列,在其关联的虚引用出队列出队前,不会彻底销毁该对象。所以可以通过检查引用队列中是否有相应的虚引用来判断对象是否已经被回收了。
Object object = new Object();
ReferenceQueue queue = new ReferenceQueue(); // get 总是返回null
// 虚引用,必须与一个引用队列关联
PhantomReference pr = new PhantomReference(object, queue);
并发、并行和串行
两个线程串行,同一时间只有一个线程在执行,当这个线程执行结束后,另一个线程再执行。
两个线程并行,同一时间两个线程都在执行,互不干扰。
两个线程并发,同一时间点,只有其中一个任务执行,两个线程交替执行,彼此干扰。
并发的三大特性
-
原子性:在一系列操作中CPU不可以被中断执行,这些操作要么全部执行要么全部不执行。
如i++就不是原子操作,它分为如下几个步骤,在值未被刷新到主存时,发生线程切换,变量被其它线程读取并操作,就会导致结果出错。- 将 i 从主存中读取到工作内存中副本中
- 把工作内存中的 i 值加1
- 将工作内存中的作刷新到主存(什么时候执行刷新不确定)
保证原子性:加锁、AtomicInteger等。
-
可见性:多个线程访问同一个变量时,其中一个线程修改了这个变量时,其它线程要能立即看到修改后的值。
总线锁协议、MESI缓存一致性协议可以用来保证可见性。- 总线锁就是使用处理器提供一个LOCK,此时其它处理器的请求将被阻塞,保证了当前处理器可以独享内存,以此保证内存操作的可见性,但是会导致系统性能下降。
- MESI协议指缓存行(cache line)的状态,分别为Modify、Exclusive、Shared、Invalid。
- CPU1 读取变量到缓存后,将变量状态置为E(独享、互斥)状态,并监听其它读取操作,如果有其它CPU读取,变量状态将变为S(分享)状态,且其它CPU读取到缓存后变量也变成S状态。
- CPU1 在修改变量时,变量将变量状态置为M(修改),其它CPU缓存中的变量状态为I(无效)。
- CPU1修改完成后,将修改的变量状态置为S,并刷新回主存,其它CPU读取后变量状态也为S。
-
有序性:虚拟机在代码编译时,会进行指令重排操作,在单线程下指令重排不会影响执行结果。但多线程下可能会出现线程安全问题。
int a = 1;
boolean flag = false;
public void write() {
// 单线程下,指令重排不影响结果
a = 2;
flag = true;
}
public void read() {
// 多线程下,如果同时执行就会出现安全问题
if (flag ) {
int ret = a * a;
}
}
volatile 如何保证有序性和可见性
TODO
内存屏障是一个CPU指令,它
线程池
为什么使用线程池?
- 降低资源消耗,提高线程利用率,避免频繁创建和销毁线程。
- 提高响应速度,在有新任务到来时,可以直接执行,而不是先创建线程,再启动线程执行。
- 提高线程可管理性,线程是稀缺资源,使用线程池可以统一分配、调优和监控。
线程池参数:
- 核心线程数,线程池默认的线程数,即使空闲也不会销毁。
- 最大线程数,线程池最多创建的线程数。
- 超出核心线程数的空闲线程最大存活时间,时间单位,达到设定时间后线程仍然空闲,线程将被销毁。
- 任务队列,用来存放待执行的任务,当核心线程数都已经在工作了,新到来的任务会加入任务队列,直到任务队列加满后,还有新任务进来,才会创建新线程处理任务。
- 线程工厂,用来定制和创建线程。
- 任务拒绝策略,会在两种情况下执行,一是在线程池关闭时,还有新任务提交过来,就会执行拒绝策略;二是在线程任务队列满后,线程池没有能力继续处理新任务时会执行拒绝策略。
线程池处理流程
- 线程池在执行任务时首先判断核心线程数是否已满,未满的话创建核心线程执行任务。
- 如果核心线程已经满,新任务被添加到任务队列。
- 如果任务队列已满,就创建临时线程执行任务。
- 如果临时线程已满,且有新任务提交,执行拒绝策略。
线程池为什么要使用阻塞队列?
- 阻塞队列在队列满后,添加新任务时会阻塞,当前任务可以得到保留,等队列中有任务被取出,当前任务可以继续添加进队列。
- 阻塞队列可以保证在队列中没有任务时,获取任务的线程阻塞,释放CPU资源。
- 阻塞队列自带阻塞和唤醒功能,无任务时,线程池利用阻塞队列
take
方法挂起线程,从而维持核心线程数存活,也不存在一直占用CPU资源的情况。
为什么要先添加队列而不是先创建临时线程(最大线程数)
核心线程是常驻线程,不会频繁创建和销毁,在核心线程能处理过来的时候没必要创建新的线程来处理。当出现峰值业务导致核心线程处理不过来时才创建临时线程,优先使用核心线程可以节约线程资源。
BeanFactory 和 ApplicationContext 有什么区别
BeanFactory
接口提供了最基础的IOC容器功能,如获取和创建Bean。
ApplicationContext
接口继承了BeanFactory
接口。同时它继承了了
MessageSource
接口,提供了消息国际化支持。ResourceLoader
接口,提供统一的资源文件访问方式。ApplicationEventPublisher
接口,提供了事件发布功能。EnvironmentCapable
接口,提供了获取系统参数、JVM参数功能。
它在加载Bean的方式上也有所不同。
BeanFactory
是懒加载的方式加载Bean,只是在第一次获取Bean的时候才加载Bean,这样做可以更快的启动应用,但是无法在启动时检查Bean配置是不是正常的。
ApplicationContext
默认在应用启动时加载Bean,这样做导致应用启动时较慢,但是可以在启动时就检查Bean的配置是否正常,因为Bean一开始就已经加载了,也可以提高后期应用运行速度。
Spring Bean 的生命周期
- 解析XML配置或注解配置,得到BeanDefinition。
- 根据BeanDefinition通过反射创建Bean对象。
- 对Bean对象进行属性填充,也就是依赖注入。
- 回调实现了
Aware
接口的方法,如BeanNameAware。 - 调用
BeanPostProcessor
的postProcessBeforeInitialization
方法。 - 调用初始化方法【init-method或@PostConstruct】。
- 调用
BeanPostProcessor
的postProcessAfterInitialization
方法,在开启AOP(@EnableAspectJAutoProxy
)时就是向容器中注入了一个后置处理器,所以在这里也会进行AOP创建。 - Spring Bean 创建完成后放入
singletonObjects
中,使用Bean时从这里面获取。【是一个Map String -> Object】 - Spring容器关闭时调用销毁方法【DisposableBean的destroy方法和@PreDestroy】
Spring Bean 的作用域
Singleton:默认单例模式,在第一次被注入时创建。
prototype:多实例,每次注入都创建一个新的Bean。
request:每个请求会创建一个Bean,同一个请求使用同一个Bean。
session:每一个会话中创建一个Bean,在同一个会话内Bean一直有效。
globalSession:在Portlet下全局作用域。
Spring 中的单例 Bean 是线程安全的吗?
不是线程安全的。Spring中的Bean应该是作为一个共享组件来使用的,不应该用于共享变量,所以Spring也没有对Bean进行多线程环境下的封装。
Spring中用到了哪些设计模式
- 简单工厂模式:根据传入参数,创建对应的产品。
BeanFactory 根据传入的BeanName来获取Bean。
- 工厂模式:
FactoryBean 调用getBean的时候最终调用factoryBean的
getObject
获取bean。
- 单例模式:在整个系统中始终只有一个实例
Spring 中的Bean默认就是单例的,单例Bean保存在
HashMap
,如果获取Bean时没有找到该Bean就通过反射创建一个。
- 动态代理
AOP用到了动态代理,在运行时为目标创建一个代理对象织入切面,执行代理方法。
- 适配器模式
DispatcherServlet
中根据不同的适配器处理不同的请求。
Spring 事务实现方式、原理
Spring 中提供两种事务操作方式,一是务使用TransactionManager
或TransactionTemplate
实现的编程式事务,二是使用@Tansactional
实现的声明式事务。
使用@Transactional
注解后,Spring会为这个类生成代理对象,在执行事务前后进行相应的事务操作,如果开启事务、关闭自动提交、正常提交、异常回滚等。
@Transactional
接收robackFor
参数指定针对方法抛出特定异常回滚事务。也可以设置readOnly=true
开启只读事务。
Spring 事务隔离级别
读未提交、读已提交、可重复读、串行化和默认,默认则是已数据库隔离级别为准。MySQL默认隔离级别是可重复读。
Spring与数据库设置的隔离级别不同,以Spring为准,如果Spring设置的隔离级别数据库不支持,则已数据库设置的隔离级别为准。
Spring 事务传播机制
事务传播机制是用来保证多个事务方法相互调用时,事务应该如何存在。
REQUIRE: 默认,需要事务,没有事新建事务,有事加入事务。
REQUIRE_NEW: 在新建事务中执行,如果当前有事务,当前事务挂起,再创建新事务。
SUPPORTS:支持已当前方式执行,当前有事务就在事务中执行,没事务就不在事务中执行。
NOT_SUPPORTED:不支持在事务中执行,如果当前有事务就挂起当前事务。
MANDATORY: 必须在事务中执行,如果当前没有事务,则抛出异常。
NEVER:不使用事务,如果当前有事务,抛出异常。
NESTED: 没有事务新建事务,有事务嵌套在当前事务中执行,嵌套事务不能单独提交,嵌套事务发生异常回滚,异常向上抛出如果被catch住就不影响外部事务。
Spring 事务什么时候会失效
Spring的事务是基于AOP实现的,如果AOP不起作用了事务就不生效。
- 比如在同一个类的方法中,以
this
形式调用了同一个类中开启了事务的方法,那么被调用的这个方法上的事务是不生效的,因为this
形式会使用代理类调用方法,那么AOP也就不生效了。 - 异常被捕获了,没有向调用者抛出,事务生效。
- 非
public
方法上使用事务注解也不会生效。 - 使用
@Transactional
的类没有被Spring管理事务不会生效。
Spring自动装配
Spring 可以通过属性、构造器及setter方法实现自动装配,默认以属性名(变量名)查找Bean进行装配,如果没有找到对应名称的Bean,就会通过要注入的参数的类型去找相应的Bean。
可以通过@Qualifier
指定要装配的Bean的名称,否则为变量名。
Spring、Spring MVC 和 Spring Boot 之间有什么区别
Spring是一个提供了IOC、AOP等一系列功能的框架,Spring MVC是在Spring基础上开发出的Web框架,用于简化Web开发。
由于Spring在整合其它框架时比较繁琐,Spring 又推出了 Spring Boot 来简化项目整合过程中各种繁琐的配置,做到开箱即用。
Spring MVC工作流程
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UBCG1C14-1654143557677)(img_1.png)]
SpringMVC流程
- 用户请求发送到
DispatcherServlet
。 DispatcherServlet
调用HandlerMapping
找到Handler
和HandlerInterceptor
并以HandlerExecutionChain
形式返回。DispatcherServlet
找到处理这个Handler
的HandlerAdapter
,并通过HandlerAdapter
调用Handler
的handle
方法处理请求,然后返回以ModelAndView
形式返回处理结果。DispatcherServlet
在调用handle
方法前后分别通过HandlerExecutionChain
遍历拦截器调用preHandle
方法和postHandle
方法。DispatcherServlet
调用ViewResolver
视图解析器处理ModelAndView
,并返回View
。DispatcherServelt
调用View
的render
方法渲染视图。
Spring MVC 中的组件
首先要理解什么是handler,handler也就是处理器,只要是能处理请求的就是处理器,如@RequestMapping
标的方法就可以看成一个handler。
HandlerMapping
:根据请求的URL资源,找到对应的Handler
。HandlerAdapter
:因为handler是不同的,比如controller、servlet,就需要一个适配器来调用Handler
处理请求。HandlerExceptionResolver
:用于处理请求中的异常ViewResolver
:视图解析器,找到视图模板,填充参数,生成View
。MultipartResolver
:用于处理文件上传请求,将请求包装成MultipartHttpRequest
来处理上传的文件。
SpringBoot自动配置原理
@SpringBootApplication
注解中包含了@SpringBootConfiguration
、@EnableAutoConfiguration
和@ComponentScan
三个注解。
@SpringBootApplication
: 同@Configuration
,声明被注解标记的类为配置类。
@EnableAutoConfiguration
:是@Import
和@AutoConfigurationPackage
的组合注解。
@Import({AutoConfigurationImportSelector.class})
:
在执行SpringBootApplication.run(…)方法的时候最终会执行AutoConfigurationImportSelector
中的getCandidateConfigurations
最终加载 META-INFO/spring.factories 资源,这个资源里面保存有自动配置类信息,最后通过ClassLoader来加载这些配置类,实现自动装配。
@ComponentScan
:配置扫描组件。
- 运行
SpringApplication.run(...)
,执行到refreshContext(context)
时,内部会解析配置类上的注解,@EnableAutoConfiguration
- 然后解析到
@EnableAutoConfiguration
上的@Import
注解引用的AutoConfigurationImportSelector
。 - 最终执行到
AutoConfigurationImportSelector
的loadFactoryNames
,并加载jar包中的META-INF/spring.factories
文件。 - spring.factories文件中配置了自动装配的配置类,然后根据这个类的配置信息完成自动装配Bean。
配置类上常用的注解:
@Configuration: 配置类
@EnableConfigurationProperties: 加载的配置
@ConditionOnWebApplication
@ConditionOnClass
@ConditionalOnMissBean
@ConditionXxxx
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EeTc5pzP-1654143557678)(img_2.png)]
为什么使用嵌入式服务器
如果使用单独的服务器,还要下载安装服务器再部署项目,如果使用内嵌式服务器,每次只需要在将项目打包成jar包就可以在安装java环境的服务上运行,切换新的服务器环境也非常方便。
MyBatis 优缺点
优:
- 基于SQL编程,与JPA基于对象编程来说,使用更加灵活。
- 同JDBC来说,MyBatis的SQL写在XML中,解除了与代码的耦合,减少了代码量,更方便对SQL进行统一管理。
- 现在项目大都是基于Spring开发的,MyBatis也能够很好的与Spring继承。
- 支持对象与数据库字段映射,可以很方便的获取和操作数据库。
缺:
- 要进行大量的SQL编写工作,程序员对SQL要有一定程度的掌握。
- SQL语句依赖于数据库类型,移植性较差。
MyBatis # 和 $
$号是Statement进行简单的变量替换,替换后的内容如果包含SQL关键字也会被解析成SQL语句中的一部分。
#号会使用PrepareStatement进行预编译,使用#可以防止SQL注入。
MyBatis 插件原理
MyBatis的插件其实就是实现Interceptor
拦截器,通过JDK动态代理的方式拦截如下加个接口:
Executor
:执行SQL方法。
ParameterHandler
:参数处理。
ResultSetHandler
:结果集处理。
StatementHandler
:SQL语句处理。
实现Interceptor
接口中的intercept
方法,其中通过invocation
可以获取到被拦截的信息。
@Component
@Intercepts(@Signature(type = ResultSetHandler.class, method = "handleResultSets", args = Statement.class))
public class MyPlugin implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 执行ResultSetHandler的handleResultSets方法 得到结果集,然后对结果集操作
Object resultSet = invocation.proceed();
return resultSet;
}
}
如下链接增删改查并打印SQL
@Component
@Intercepts(
{
@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}),
@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}),
@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
}
)
public class ShowSqlPlugin implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0];
Object parameter = invocation.getArgs()[1];
BoundSql boundSql = mappedStatement.getBoundSql(parameter);
Configuration configuration = mappedStatement.getConfiguration();
// 通过 boundSql + configuration 去拼接SQL 并打印
return invocation.proceed();
}
}
索引的基本原理
索引用来快速查找具有特定值的记录,就像图书的目录一样,可以用来快速查找某个内容、数据。
创建索引会对索引列进行排序,然后在排序内容上关联数据地址信息,通过索引查询的时候找到索引位置就找到数据地址,从而查询到数据。
MySQL聚簇索引和非聚簇索引的区别
都是采用B+树数据结构。
聚簇索引叶子节点也是数据节点,用于存放的是数据行,
非聚簇索引叶子节点是索引节点,用于存放指针,指向索引对应的数据行。
- 查询时通过聚簇索引可以直接获取数据,非聚簇索引需要二次查询才能获取数据,相比来说聚簇索引效率更高(非覆盖索引的情况下,就是查询包含出索引外的其它字段时)。
- 聚簇索引是按一定顺序组织的(物理存储顺序有关),所以范围查询效率更高。
- 聚簇索引和数据是一起的,索引排序,相当于数据排序,所以聚簇索引适合排序场景;非聚簇索引数据和索引分离,查询出的数据也是无序的。
劣势:
- 聚簇索引如果是随机值,可能导致存储稀疏,引起全表扫描而变得更慢。
- 辅助索引存的是主键值,如果索引值过大,会导致叶子节点占用更多物理空间。
MySQL索引结构
MySQL索引数据结构有B+树和哈希两种。InnoDB默认索引实现为B+树。
B+树从根节点到每个叶子节点高度差值不超过1,且同级节点有指针链接,利用指针双向移动,可以使查询扫描效率更高。
索引设计原则
索引设计的目的就是使查询更快、占用空间更小。并不是每个表都需要建立索引,过多的索引反而导致占用更多空间,还会增加数据库维护索引的开销。
- 更新频繁的列不适合创建索引。
- 查询中很少涉及的列、重复值较多的列,不适合创建索引。
- 列数据较大时不适合创建索引。
- 经常出现在where子句、连接子句中、order by、group by、on中的列添加索引,。
- 尽量扩展索引,不要新加索引,,如原来有a列上有个索引,现在要给b列加索引,就使用组合索引,insert和update可能会导致重构索引。
组合索引及最左匹配
组合索引最频繁出现、重复值越少的列放在组合索引左左边,组合索引列最好小于3个。
从最左边开始匹配,直到遇到>、<、between、like
就停止。
index(a,b,c)
where a=3 只使用了a
where a=3 and b=5 使用了a,b
where a=3 and b=5 and c=4 使用了a,b,c
where b=3 or where c=4 没有使用索引
where a=3 and c=4 仅使用了a 【没有匹配到b】
where a=3 and b>10 and c=7 使用了a,b
where a=3 and b like 'xx%' and c=7 使用了a,b
表优化
- 表字段不要为null,设置默认值,如0。null查询会全表扫描。
- 根据数据长度选取合适的类型,从大到小依次是tinyint、smallint、mediumint、int、bigint,如果是非负数可以使用unsigned(无符号类型的)。
- 单表字段不要太多,20以内比较合适。
- 尽量不使用外键约束,使用程序来约束。
- 添加合适的索引
SQL 优化
- where 或 order by 列上加索引。
- where子句中使用null值判断会导致索引失效,从而导致全表扫描,把null值设定为一个默认值,通过默认值查询。
- 在like查询时,%在最右边才会使用到索引。
- or查询时左右都是索引列,索引才会生效,如果只有一列是索引列可以使用union查询。
- in和not in中如果是连续的数值,使用between and 代替,如果不是连续的值使用exists查询。
- 查询条件中的列不要参数运算,元素可以在条件列另一边。
MySQL锁有哪些类型
- 基于属性:共享锁和排他锁
- 基于锁状态:意向共享锁和意向排他锁
- 基于粒度:表级锁、行级锁(InnoDB)、页级锁(BDB引擎)
- 实现方式:乐观锁和悲观锁
修改表结构 加 表级锁
更新数据未使用索引时,行锁会上升为表级锁
更新数据时使用索引会使用行级锁
select … for update 使用到索引时会使用行级锁,没使用索引就是表级锁
MySQL 共享锁和排他锁
- 共享锁:共享锁也叫读锁,当一个事务给数据加上读锁之后,其它事务也可以给数据加读锁,但是不能再加写锁了,避免数据在读取时被修改,不可重复读。
select…lock in share mode.
- 排他锁:排他锁也叫写锁,当一个事务给数据加上写锁之后,其它事务就不能给数据加锁了,避免出现脏读。
select…from update.
MySQL 意向共享锁 和 意向排他锁
-
表锁和行锁锁定范围不同,会引发冲突,在加表锁时要判断是否有行锁。
-
意向锁是表级锁,当在记录上加读锁或写锁时,首先要在表上加对应的意向锁,这样在对表加表锁时就可以判断是否有行锁了,意向锁之间不会有冲突,它只会阻塞表级锁。
表级锁、行级锁(InnoDB)、页级锁(BDB)
- 表级锁:对整个数据表加锁,并发低,性能差。
加锁:lock tables tableName; select * for update 没有使用或命中索引时;update没有命中索引时。
解锁:unlock; 自动,提交事务后。
- 行级锁:
- 记录锁:对某一行数据加锁
通过索引加锁查询到某一行记录。
- 间隙锁:对某一索引区间、某一索引前或后的范围加锁,锁住的是一个区间,而不是对区间范围内的数据加锁。
查询时使用索引查询某一范围记录
- 临键锁:使用非唯一索引时,会添加临建锁,会锁住一个左闭右开区间范围。
查询时使用的是非唯一索引字段
- 记录锁:对某一行数据加锁
- 页级锁:锁定相邻的一组数据
只有DBD支持页级锁,
乐观锁和悲观锁
- 乐观锁不对数据加锁,而是通过设定版本号或者时间戳,在更新数据时判断版本号或时间戳来判断数据是否被修改,来决定当前更新的数据是否为最新数据。
- 悲观锁:只要操作数据就对数据加锁,来防止数据冲突。
#
查看锁竞争情况
show status like
‘innodb_row_lock%
’;
innodb_row_lock_time
: 系统启动到现在锁定总时间
innodb_row_lock_time_avg: 平均锁
(等待
)时间
innodb_row_lock_max
:最长一次等待时间
innodb_row_lock_waits
:总等待次数
如果我们定义了主键(PRIMARY KEY),那么 InnoDB 会选择主键作为聚集索引
如果没有显式定义主键,则 InnoDB 会选择第一个不包含有 NULL 值的唯一索引作为主键索引
如果也没有这样的唯一索引,则 InnoDB 会选择内置 6 字节长的 ROW ID 作为隐藏的聚集索引,它会随着行记录的写入而主键递增
锁在 InnoDB 中是基于索引实现的,所以一旦某个加锁操作没有使用索引,那么该锁就会退化为表锁,因为查询没有使用索引,会进行全表扫描,然后把每一个隐藏的聚集索引都锁住了。
除了直接在主键索引加锁,我们还可以通过辅助索引找到相应主键索引后再加锁。
在辅助索引里面, 索引存储的是二级索引和主键的值。 比如name=4,存储的是name的索引和主键id的值4。
而主键索引里面除了索引之外,还存储了完整的数据。所以我们通过辅助索引锁定一行数据的时候,它跟我们检索数据的步骤是一样的,会通过主键值找到主键索引,然后也锁定。
MySQL 执行计划
通过explain
来查询SQL执行计划,主要看SQL有没有使用索引。
通过结果中的key
字段来查看有没有使用索引,rows
字段来查看查询扫描了多少行数据。
通过explain
查询SQL执行计划。
id: 编号,有几个select就显示几行,id是按select出现的顺序增长的,ID值越大执行优先级越高,id相同从上往下执行。id为null最后执行。
selectType: select 子句的类型
- simple: 不包含UNION或子查询
- primary: 最外层查询(包含子查询)
- subquery: 子查询中的第一个select
- union: UNION 后面的查询
- dependent union:
UNION后面的查询,但是和外部查询有关,这个union是子查询用到了外层查询的内容如表explain select * from tb_user where sex in (select sex from tb_user where sex='1' union select sex from tb_user where sex='2');
- UNION RESULT:union 结果集
- DEPENDENT SUBQUERY
子查询中的第一个查询,与外层查询关联explain select id,name,(select name from tb_dep a where a.id=b.deptId) from tb_user b;
- DERIVED: FROM中的子查询 explain select * from (select * from tb_user where sex=‘man’) b;
事务基本特性
- 原子性:事务应该被看做一个整体,不可分割,事务内的操作要么都执行,要么都不执行,不存在其它状态。
- 一致性:事务操作使用数据库从一种有效状态转变为另一种有效状态,可以从两个方面来理解,数据库层面来说数据应该满足数据库定义的约束,如字段类型、唯一约束等等。从业务层面来说就是事务要满足业务要求,如转账成功或失败前后数据状态。
- 隔离性:事务之间的操作是隔离。事务之间的隔离程度又分成不同的隔离级别。
- 持久性:事务一但被提交,对数据库的更改就是永久的,即使遇到故障数据也不会丢失。
隔离级别
- 读未提交:一个事务可以读取其它事务未提交的数据。
如果其它事务回滚会造成脏读;
如果在其它事务修改前后分别读取数据,两次数据读取不一致又会造成不可重复读;
如果读取某一范围内的数据时,没发现要插入的数据,要进行插入操作时其它事务插入了这条数据,导致无法插入,会造成幻读。
- 读已提交:一个事务只能读取其它事务已提交的数据
无法避免幻读,不可重复读
- 可重复读:在同一个事务中多次读取同一个记录结果是一样的。
只针对一条记录,还是会产生幻读
- 可串行化:事务串行执行,可避免脏读、不可重复读和幻读,但是导致性能降低。
慢查询
在写SQL的时候都会在本机和测试库中跑一下SQL,看下SQL的查询效率,如果后期还有慢查询的话运维会收到慢查询警报反馈到开发。
在拿到慢查询的时候主要要搞清除是什么导致了慢查询,主要是看查询时索引有没有命中、是否加载了业务中没有用到的多余的数据或者是数据量太大导致的。
搞清除这些后再根据对应的情况来出里:
- 如果是索引问题,优化索引,优化查询的SQL。
- 如果是加载了多余的列,就删除多余的部分。
- 如果是数据量太大,就根据业务情况选取不同的对策:
【横向分表:数据根据一定规则存储在不同表,纵向分表:拆分不经常查询的字段,减少表空间占用,提到检索效率】
- 数据在同一个表考虑分表来做
- 数据在不同表考虑在代码层面来做,比如分多次查询,然后拼接数据。
- 如果是因为高并发导致数据库已经扛不住了【链接数不够,高并发导致磁盘读写性能跟不上、数据库可用容量不够】就可以考虑分库来做。
MySQL 如何保证 ACID
- 原子性是通过undo log来保证的,当事务需要回滚的时候,通过undo log撤销已经执行成功的SQL。【undo log 会记录增删改的操作,如果一个insert需要回滚,就根据undo log删除对应增加的数据】
- 一致性,在数据库层面是通过数据库列类型、约束类型这些保证的,在业务层面是通过程序代码保证的。
- 隔离性:通过MVCC【多版本并发控制】保证的。
- 持久性:通过redo log来保证,数据库宕机后,已提交的事务后续会通过redo log持久化。
MVCC
MVCC是多版本控制,读取的是通过读取类似数据快照方式保存下来的数据,每个事务session只能看到自己特定版本的数据,这样就避免了读锁和写锁的冲突。
MVCC只在读已提交和可重复读两种隔离级别上工作。读未提交会造成脏读,不符合当前事务版本行;串行化事务串行执行,没必要做MVCC处理了。
MySQL主从同步原理
主从复制的原理是主库记录数据库中的所有变更到binlog中,从节点读取binlog中的内容,根据binlog中的内容对数据库进行更改,实现数据同步。
MySQL主从同步主要涉及了三个线程,分别是Master的binlog dump thread 和 slaver的I/O thread 和 SQL thread。
- 当binlog有变动时,主节点binlog dump线程读取其内容发送给从节点。
- 从节点的I/O线程接收binlog内容,并写入relay log文件中。
- 从节点的SQL线程读取relay log文件中的内容对数所更新,最终保证主从数据库的一致性。
注:主从节点使用binglog文件 + position偏移量来定位同步位置,从节点会保存偏移量,如果从节点发生宕机,则人从保存的偏移量位置开始继续同步。
主从同步默认是异步的,如果主库挂了,从库处理失败了并升为主库后,日志丢失,导致数据丢失。
- 全同步复制,主库写入binlog后,等所有从库完成后才返回给客户端。
- 半同步复制,主库写入binlog后,从库写入日志后返回确认消息,主存收到至少一个从库的确认消息后就完成写操作。
InnoDB 和 MyISAM
- InnoDB支持事务,MyISAM不支持事务。【InnoDB每条语句都封装在事务中,自动提交】
- InnoDB支持外键,MyISAM不支持外键。【拥有外键的InnoDB表转成MyISAM表会失败】
- InnoDB是聚簇索引,MyISAM是非聚簇索引。
- 查询行数【count】时,MyISAM保存了具体行数,InnoDB查询可能要走索引或全表扫描
count(1) 这个查询来说,InnoDB 引擎会去找到一个最小的索引树去遍历(不一定是主键索引),但是不会读取数据,而是读到一个叶子节点,就返回 1,最后将结果累加。
count(id) id是主键,遍历主键索引。
count(username) username非索引,查询时遍历整张表,
MySQL数据库中索引类型对数据库性能的影响
索引类型:
- 普通索引:
- 唯一索引:保证数据唯一性。
- 主键:一张表中只能定义一个唯一索引,主键用于唯一标识一条数据。
- 联合索引:可以覆盖多个数据列。
- 全文索引:可以提升检索字段是否包含效率
索引可以提高数据查询的速度,但是会影响数据插入、更新、删除的速度,因为在执行这些操作时会操作索引。
过多的索引就会占用大量的物理空间,同时也会增加数据库维护索引的成本,导致数据性能降低,所以要根据业务场景创建合适的索引。
MySQL 优化 like 查询效率
Redis 持久化 RDB 和 AOF
- RDB:默认打开,在指定的间隔时间内,将内存中的数据写入磁盘。
save "" // 关闭RDB
save 900 1 开启RDB
RDB(Redis Database)有两种模式,分别是SAVE和BGSAVE【background save,默认模式,SAVE和BGSAVE是两个命令分别用来开启这两种模式】
- SAVE模式在主进程中执行将内存的数据保存到临时文件,写入完成后替换持久化文件(dump.rdb【默认】),持久化数据过程中,主进程会阻塞。
- BGSAVE模式会fork子进程,持久化数据的操作是在子进程中完成的。如果此时有新的值写入或修改,会创建一个副本,在数据副本上操作,不影响持久化。
在 redis.conf 中配置持久化策略 SAVE 900 1 ,900S内有一次变动,就执行持久化操作。
因为是内存数据库,所以会在启动时加持久化数据,退出时持久化数据。
存在的问题:
不满足条件的间隔时间内没有执行持久化,并且Redis异常退出,最终会导致数据没被持久化,丢失数据。
数据量大时,子进程持久化操作可能占用大量的资源。
- AOF:默认关闭
appendonly no // 关闭AOF
appendonly yes // 打开AOF
AOF (Append Only file): 将所有写操作命令保存在AOF文件中,还原数据的时候通过执行这些命令来还原。
随着命令的增多,AOF文件也会越来越大,Redis通过重写机制【bgrewriteaof命令】也就是读取服务器现有键值对,将每个键值对重写为一条写命令,并生成新的文件来替换原来的AOF文件。
AOF重写重写操作是放在子进程中进行的,Redis通过AOF重写缓冲来记录重写期间的数据操作命令,当主进程收到重写完成命令后,会将AOF缓冲区命令写入AOF文件。AOF记录是在命令执行成功后写入的,避免了命令正确性校验。
appendfsync:写入策略,everysec【默认】每秒写入,always 每个命令之后写入,no 由操作系统决定何时写入【丢失数据风险大】
auto-aof-rewrite-percentage:默认为100,如果为0则禁用重写,当前写入日志文件的大小超过上一次rewrite之后的文件大小的百分之后触发重写
auto-aof-rewrite-min-size:设置最小AOF文件大小,默认64M,auto-aof-rewrite-percentage 为100,上次重写之后文件大小为10M,那么将在大于10M的100%也就是20M后触发重写,但是文件大小又小于 auto-aof-rewrite-min-size
【64】,所以不会发生重写。
RDB 和 AOF 都配置了优先加载AOF。
大数据量时,RDB效率高,AOF命令重放效率低。
运行时AOF执行更频繁,效率相对低。
Redis 过期键删除策略
一般用于过期的策略有定时删除、惰性删除和定期删除,在Redis中同时使用惰性删除和定期删除两种策略。
- 定时删除:每创建一个过期key就创建一个定时器,让定时器在key过期时删除这个key。
会占用大量CPU资源。
- 惰性删除:不主动删除key,在每次获取key的时候检查有没有过期,过期就删除,返回null。
太多过期的key长时间没获取,会占用大量内存。
- 定期删除:每隔一段时间执行一次删除key的操作。
相对于定时删除,会积压数据,占用内存,但是不会大量占用CPU资源。
对于惰性删除,会占用更多CPU资源,但是不会大量占用内存。
Redis同时使用 惰性删除 和 定期删除 两种策略。
Redis线程模型?Redis单线程为什么这么快?
- Redis是基于内存的数据库,数据在内存中的操作是非常快的。
- 采用单线程可以避免可以避免线程上下文切换和多线程下竞争问题影响Redis性能。
- Redis是单线程的,内部采用IO多路复用模型,提升了Redis处理性能。
Redis IO多路复用
Redis是单线程的,所有操作都是线性的,如果一个命令处理的时间过长,那么就会造成后续请求阻塞,Redis中的IO多路复用就是用来解决这个问题的。
TODO:
缓存雪崩、缓存穿透、缓存击穿
- 缓存雪崩:同一时间内,大量缓存失效,导致所有请求都落到数据库上,导致数据库在短时间内承受大量请求,导致数据库服务故障,进面引发整个服务故障。
【区别于缓存击穿,缓存击穿是某一个key失效,这个key上有大量请求,缓存雪崩是多个key失效,每个key都有很多请求导致这些请求都去访问数据库】
解决办法:
- 加锁:在没有获取到缓存数据时,加互斥锁,锁内进行读取数据库数据并写入缓存操作,读数据前要二次判断是否有缓存,避免其它请求重复从数据库访问,加锁会导致其它线程阻塞,高并发场景不适用。
- 数据预热:上线前就把缓存数据加载进缓存中,避免在用户请求时直接访问数据库。
- 如果对过期时间没有硬性要求,那么在设置过期时间的时候尽量随机【预热时设置随机,后期请求加载缓存本身就有随机性】,防止同一时间大量缓存失效。
- 不使用过期缓存,使用定期更新缓存的策略。
- 缓存穿透:查询缓存中的数据在缓存和数据库中都不存在,导致每次查询都访问数据库,导致数据库访问量增大。
解决办法:
- 当数据库也不存在时就缓存空对象。会存在一个问题就是数据库加入了这个数据时如何更新这个空对象【添加时刷新、过期时间】。【数据变化性高的场景-需要占用更多空间来保存空对象】
- 使用bitmap做布隆过滤器,将所有存在的key用布隆过滤器保存起来,请求缓存数据时先通过布隆过滤器判断key是否存在,不存在则不继续请求。【数据相对固定-key相对固定的情况】
- 缓存击穿:当某个热点Key失效,并且在高并发的情况下,会有大量请求访问数据库。
key失效不存在时加锁,加锁后执行从数据库获取数据写入缓存的操作。可以加锁也可以用
setnx
,setnx
当数据不存在时执行返回成功,才能做后续查库写缓存操作,后续其它setnx
就是失败的,失败后只做get获取缓存操作。
热点key不失效。
Redis 事务机制
Redis 事务,不像MySQL一样有回滚机制,要么执行要么不执行。
- MULTI:开启一个事务
- DISCARD:取消事务
- EXEC:开始执行事务
- WATCH:
watch key [key]...
,在执行multi
之前执行,可以监控key的变化,如果被监控的key值被修改了,事务就会取消执行【执行exec时命令事务中的命令不会被执行】 - UNWATCH:
UNWATCH
,可以取消对key的监控。
WATCH 乐观锁 及 秒杀超卖、库存遗留问题。
可以利用WATCH
命令实现CAS乐观锁。
秒杀超卖问题分析:并发读取到库存为1个,多个线程都执行减库存操作,所以出现超卖。
解决问题:
- WATCH 库存 KEY
- 开启事务
- 判断如果成功就减库存
- 执行事务
秒杀超卖问题如何解决:并发读取库存为1个,因为key被watch了,所以当第一个请求完成减库存操作时,后续读取为1的请求减库存操作就不会执行。
库存遗留:并发时读取的是同一个值,如果有100个库存,并发数为1000,其中1000个并发读取库存时都为100,那么最终1000个并发只卖了1个。
Redis 集群方案
- 主从
1.从节点连接上主节点后,会向主节点发送数据同步消息。
2.主节点收到数据同步消息后,会持久化当前数据到.rdb文件,并把.rdb文件发送给从节点,从节点读取rdb文件同步数据。
3.之后主节点每次写数据时,将写命令发送给从节点,进行增量复制。优点:
1.读写分离。
2.实现容灾,不会因单台服务宕机丢失数据,进行数据快速恢复。
注:如果主节点挂了,需要手动执行SLAVEOF NO ONE
使从节点变成主节点,如果需要自动提升可以使用哨兵模式。
- 哨兵模式:
哨兵进程通过发送命令,让Redis服务返回其运行状态,监控Redis服务。
如果监控到主节点挂了,会自动切换某一个从节点为节点,并通知其它从节点。
注:在Java中JedisSentinelPool
连接池。
- Redis集群cluster
实现扩容;分摊压力,每个节点负责一段插槽内容;无中心化配置,集群每个节点都能操作整个集群中的数据。
Redis 容量不够,如何扩容?并发写操作Redis如何分摊?可以使用集群来解决。
Redis集群实现了对Redis的水平扩容,即启动N个节点,将数据分布存储在这N个节点中,每个节点中的数据为1/N。
Redis通过分区提供一定程度的可用性,即使其中某一部分节点宕机,集群也可以继续处理命令。
集群节点中主节点宕机,从节点会提升为主节点。
如果集群某一节点主从都宕机,根据配置可以是集群不提供服务或者对应的集群节点【对应的插槽】不提供服务。
连接集群redis-cli -c
。一个Redis集群从0-16383共16384个插槽hash slot,集群中每个节点处理一部分插槽,集群通过计算key属于哪个插槽来,那么这个key就由对应插槽的节点来处理。
mset name zs age 18
这样有多个key无法计算插槽,要指定组来计算插槽如mset name{user} zs age{user} 18}
。
Java通过JedisCluster来操作集群。Redis 对key进行Hash运算(CRC16算法),然后将结果对16384取模得到插槽位置,并根据节点与槽位映射信息找到具体的节点。
【一组多从为集群的一个节点】
注:主-从复制有有延迟,延迟时间受网络和从节点数量限制。
Redis 分布式锁
setnx
获取锁,del
释放锁,设定过期时间避免长时间持有锁,这是3个操作还要保证原子性。
set key v nx ex 过期时间
在设置值的时候就设置过期时间,保证即使没有释放锁,也能过期释放。通过值来保证删除时是不是同一把锁。
无法保证原子性,因为在判断UUID为同一把锁过后,key失效,其它线程获取了锁,当前线程删除的就是别的线程的锁。
CAP 理论
- C,Consistency,即一致性。
更新操作成功后,所有节点在同一时间获取到的数据完全一致。
对于客户端来说,数据更新成功后,如何客户端获取的数据是一致。
对于服务端来说,数据更新成功后,如何保证数据同步到整个服务器。
对于一致性,又可以分为强一致性、弱一致性和最终一致性。
- 强一致性:数据更新后,其它节点立即同步,不允许访问到更新前的数据
- 弱一致性:数据更新后,允许部分或全部节点访问到更新前的数据。
- 最终一致性:数据更新后,在一定时间内,允许访问的更新前的数据,但是这段时间过后,不允许访问到更新前的数据。
- A,Availability,即可用性。
服务一直可用并且以正常的时间响应,不允许出现操作失败或响应超时等情况。
- P,Partition Tolerance,分区容错性。
分区容错性是指当出现网络分区错误时(分布式系统节点通过网络交互,不能困为某个节点出现网络问题而导致所有服务不可用),也能提供服务。
为什么不能同时满足CAP?
分布式系统的前提是要保证分区容错性,有就是一定能提供服务,即使是响应友好提示。
- CP,不能满足A的情况
在此前提下如果要保证一致性,那么就无法满足可用性,因为当数据修改后,需要立即同步给其它服务,由于出现网络分区故障的服务无法同步数据来保证数据一致,那么出现网络分区故障的服务就不能提供可用性服务。【返回的数据是不正确的、或者响应有好提示】
- AP,不能满足C的情况
同样的如果要满足可用性,即使服务故障服务仍然提供可用性服务,也就是能够在正常时间内响应正常数据,在出现网络分区故障时,数据无法同步保证一致性。
Base 理论
Base理论是由于CAP理论中一致性和可用性不能同时满足而权衡出来的结果,它是基于CAP理论演化而来的。它放弃了强一致性,采用最终一致来换取服务的高可用性。
- Basically Available:基本可用
通过时间上的损失,获取基本可用,原本正常响应时间为0.5S,因为服务故障允许服务间超时后返回降级策略。
通过功能上的损失,获取基本可用,突然间系统访问量巨增,允许损失非核心功能,来满足主要功能,或者部分访问降级。
- Soft state:软状态
允许数据存在中间状态,多个数据副本可以是不一致的,避免要求强一致性而影响系统性能,
- Eventually consistent:最终一致性
数据不能一直是软状态,在经过一段后,数据最终是一致的。
负载均衡算法和类型
-
负载均衡算法
- 轮询
- 加权轮询:性能好的机器权重大,提供更多的服务。
- 随机
- 加权随机
- 源地址哈希:对客户端Ip进行哈希运算得到一个数值,然后对提供服务的节点数取模获确定提供服务的机器。
- 最小连接数:优先使用提供服务少的节点
-
负载均衡类型
- DNS负载均衡,将一个域名配置多条记录指向一同的Ip,DNS解析是根据访问者就近位置返回对应的IP。
- 硬件负载均衡:F5、A10。
- 软件负载均衡:Nginx、HAProxy。
Nginx负载均衡策略
- 轮询
upstream alias {
server 127.0.0.1:8080;
server 127.0.0.1:8081;
}
server {
listen 80;
server_name localhost;
location / {
proxy_pass http://alias;
}
}
- 加权轮询
upstream alias {
server 127.0.0.1:8080 weight=3;
server 127.0.0.1:8081 weight=2;
}
server {
listen 80;
server_name localhost;
location / {
proxy_pass http://alias;
}
}
- IP绑定
ip_hash 计算 请求ip通过哈希算法与服务绑定,相同的ip访问同一个服务
upstream alias {
server 127.0.0.1:8080;
server 127.0.0.1:8081;
ip_hash;
}
server {
listen 80;
server_name localhost;
location / {
proxy_pass http://alias;
}
}
- 最小连接
least_conn
upstream foo {
least_conn;
server localhost:8001 weight=2;
server localhost:8002;
server localhost:8003 backup;
server localhost:8004 max_fails=3 fail_timeout=20s;
}
- url绑定
upstream somestream {
hash $request_uri;
server 192.168.244.1:8080;
server 192.168.244.2:8080;
server 192.168.244.3:8080;
server 192.168.244.4:8080;
}
server {
listen 8081 default;
server_name test.csdn.net;
charset utf-8;
location /get {
proxy_pass http://somestream;
}
}
扩展:主备,当主服务宕机,可以使用备用服务提供服务。
upstream bakend {
server 192.168.0.14;
server 192.168.0.15 backup;
}
Nginx 7层及4层负载
TCP/IP 四层模型: 应用层、传输层,网际层、网络访问层
OSI七层模型:【应用层、表示层、会话层】(对应tcp/ip应用层)、传输层、网络层、【数据链路层、物理层】(对应tcp/ip网络访问层)
Nginx 4层(传输层)是基于 ip + 端口实现负载均衡,
Nginx 7层(应用层)是基于 URL等应用信息实现负载均衡,7层要解析数据包,获取如参数、请求头、Cookie等。相对4层效率较低。
TCP/IP 及 OSI
TODO
分布式架构下的Session共享
- JTW无状态服务。
- session复制:修改Tomcat配置,但是每台服务器都要占用内存来保存这些session,要占用资源进行session同步。
- Cookie中传输:前端存储,安全性差,数据量【50条左右】、数据大小【4kB左右】有限。
- Nginx Ip绑定,同一Ip访问同一个服务,共用同一个IP。
- 数据库存储。
- 缓存中存储。
RPC
RPC:Remote procedure【 prəˈsiːdʒər】call,即远程过程调用。
- 什么是RPC?
如果一个应用部署在A服务上,想要调用B服务器上的应用提供的方法,由于不在一个内存空间,不能直接调用,需要通过网络来表达调用的语义和传达调用数据,就像是在调用本地方法一样。
- 为什么不用Http,而是RPC?
RPC是一个完整远程调用方案,它通常包括通信协议和序列化协议,通信协议有的使用http协议【如gRPC】,自定义报文的tcp协议【如dubblo】,序列化协议有基于文本编码的xml、json或基于二进制的protobuf和hessian等。
而Http是一个通信协议,并不是一个完整的远程调用方案。
- 为什么传输协议有的是TCP有的是Http?
基于HTTP的RPC,可以使用http连接池,避免频繁的创建和销毁连接,对传输内容也可以进行二进制虚拟化,从这几点来看与TCP的差别并不大。
它们的主要差别是在传输协议上,http传输协议中header部分有很多冗余的部分,像Content-type、Expires、Last-Modified等,会增加传输内容。
相比之下,TCP可以精简传输内容,效率更高,即使是几个字节在遇到巨大请求量时也能节省很多传输成本,所以在对性能要求较高的系统中通常使用的是TCP。
RESTful
REST:(Representational State Transfer),表象层状态转变,是一种web软件架构风格,只一种约束。
主要包含三个概念:资源、表象层、状态转变。
- 资源:一个URL代表一个资源
- 表象层:资源类型,如文本、json、html等。
- 状态转变:HTTP动词,GET、POST、PUT、DELETE来表示状的转变。
举例子:对用户的增删改查。
RMI
RMI是Java版本的远程方法调用,可以实现跨JVM调用Java方法,即一个JVM中的程序调用另一个JVM中的Java方法。
分布式ID的生成方案
- UUID:32位16进制数,它是根据网卡或mac地址、时间戳、时钟序列等其它可能的数字生成的唯一的字符串。
缺点:
- 确定是UUID是无序的,不能保证趋势递增。
- 是字符串,存储性能差,查询效率慢。
- 不具备业务含义,可读性差
- 数据库自增ID
CREATE TABLE `t_test`
(
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(1) NOT NULL DEFAULT '',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_name` (`name`) USING BTREE
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4;
# 因为name 有唯一约束【能找到相应的记录】,所以在执行 REPLACE INTO 时首先会删除对应【能找到对应的记录】的记录,然后再插入新的值,这样数据库中只有一条记录,但是每次执行都能更新id
REPLACE INTO t_test (`name`)
VALUES ('a')
如果ID生成频繁会造成数据库的访问压力。
可以使用多主模式做负载,每个MySQL设置不同的起始值和步长。
如:机器1起始值为1步长为3,机器2起始值为2步长为3,机器3起始值为3步长为3.
-
Redis自增
Redis单行程保证唯一。集群设置步长和起始值。 -
号段模式
从数据库获取某一区间段的ID,比如0-1000,当这些Id都用完后,再获取一个新的区间段ID。
- 雪花算法
不依赖第三方。
雪花算法生成的是一个Long型的值,8位64Bit,符号为不用,默认为0。前41位是时间戳,接着10位是工作机器ID,后12位为自增序列号,最后转换成10进制。
不同的机器分配的不的机器ID,10Bit可以设置1024个机器,12Bit可以设置4096个序列号,使用毫秒级时间戳也就是说一台机器一毫秒最多可生成4096个ID。
分布式锁
- 利用数据库主键冲突一次只有一个线程能获取锁,非阻塞、不可重入、单点、无失效时间。
- 使用Redis通过setnx实现,锁过期后其它线程获取了锁,当前线程删除了其它线程的锁,同时过期后任务没执行完成也无法续约,锁也无法重入【方法递归获取同一个锁】,所以一般使用Redission来使用分布式锁。
它使用一个Hash结构来存储数据,key为锁的名字,field是客户端ID,value是重入次数。
key = 锁名称,field = UUID + 线程ID,value = 重入次数
加锁:
exists 锁名称是否存在; 不存在执行 hset 添加锁信息。 接着执行expire key 时间设置过期时间。
互斥:
exists 锁名称 是否存在;hexists 锁名称 field 判断客户端是否存在,已经被其它客户端获取锁,返回pttl key,剩余过期时间(毫秒),进入循环不断尝试加锁。
续约:
Redission通过 Watch Dog机制来解决锁续约问题,key的默认我生存时间为30S,Watch dog 是一个后台线程,它每隔10S(30s / 3)检查key是否存在,如果存在就续约30s,来不断延长key的过期时间。
解锁:
删除这个hash并广播,通知watch dog停止刷新。
Redission内部使用lua脚步保证redis操作的原子性,保证设置、删除的是同一个数据。
- 使用zookeeper通过创建临时节点来实现分布式锁,当服务获取锁后挂掉与zk连接的session断开,临时节点就会自动删除。
分布式事务解决方案
XA 协议
XA 协议最早的分布式事务模型,它规定 应用程序(AP), 资源管理器(RM)、 事务协调器(TM)、 通信资源管理器(CRM)4个组件 。
两阶段提交协议中定义资源管理器和事务协调器的接口标准。包括如:
xa_open()/xa_close():来建立/关闭与资源管理器的连接。
xa_start()/xa_end(): 开始/结束一个本地事务。
两阶段提交协议被用来管理分布式事务,可以保证数据的强一致性。
两阶段提交协议执行过程:
第一阶段:
1. 事务管理器通知参与该事务的各资源管理器开始准备事务。
2. 资源管理器接收消息后开始准备,如写日志、执行事务但不提交。然后将准备就绪的消息返回事务管理器。
第二阶段:
1. 事务管理器收到资源管理器消息后,基于投票结果进行决策,如果有任意一个回复失败,则发送回滚命令,否则发送提交命令。
2. 各个资源管理器在接收到二阶段提交或回滚命令后执行并将结果返回给事务协调器。
缺点:
1. 同步阻塞问题,执行过程中所有参与节点是事务阻塞型的——参与者占用公共资源时,其它服务不能访问公共资源不得不处于阻塞状态。
2. 单点故障问题,事务协调器故障,参与者会一直阻塞下去,并且锁定的资源也可能得不到释放。
3. 数据不一致问题,事务协调器在发送commit时,部分节点发生网络故障,导致部分节点执行commit,部分节点未执行,从而出现数据不一致问题。
4. 状态不确定,事务协调器发送commit后宕机,且唯一收到commit的节点也宕机了,导致新的事务协调器不知道事务是否已经被提交了。
TCC (try-confirm-cancel)
TCC 将整个业务逻辑的每个分支显示的分成try、confirm、cancel这3个操作阶段。在try阶段完成事务准备工作,confirm完成事务提交工作,cancel完成事务回滚工作。
TCC执行过程是:
1. 应用程序向事务协调器发起开始事务请求。
2. 应用程序调用所有事务参与者的try接口。
3. 应用程序根据try接口是否都成功决定是否提交或回滚,并发送请求到事务协调器。
4. 事务协调器根据接收到请求决定调用confirm或cancel接口来提交或回滚事务,如果接口调用失败会重试。
TCC方案让应用自己定义数据库操作粒度(自己编写提交、回滚事务的方法),降低锁冲突,提高吞吐量。
缺点:
对应用侵入性强,实现难度大,业务逻辑每个分支都要实现try、confirm和cancel这3个操作,并且confirm和concel接口必须实现幂等性
基于消息的最终一致性
基于消息的最终一致性执行过程:
1. 执行一个事务,并将消息在同一个事务中保存到数据库中,消息状态为待发送。
2. 消息保存后通过定时任务轮序待发送状态的消息,并将消息投递给消息队列,投递失败则重试,知道成功收到ACK确认消息,并将状态更新为已发送。
3. 如果下游系统消费消息失败,则不断重试,最终做到两个系统的数据最终一致。
确定:
对应用侵入性强,成本高。
服务采用基于消息的最终一致性
下单为例:
1. 提交下单请求到账户服务,账户冻结下单金额,保存一条下单消息到数据库,下单消息包含待生成订单的订单ID,保证消息与订单关联。
2. 定时任务100毫秒读取一次代发送消息内容,每次读取最多500条数据,然后发送给MQ。最多读取1000条,如果超过2500条时。
3. 订单服务接收消息后创建订单,修改消息为已处理。
Seata
Seata中主要有3个角色:TM(Transaction Manager)、RM(Resource Manager)和 TC(Transaction Coordinator)。
- TM(事务管理管理器):与TC交互,开启、提交、回滚全局事务。
- RM(资源管理器):与TC交互,负责资源相关处理,包括处理分支事务注册、分支事务状态上报。
- TC(事务协调器):维护全局事务和分支事务状态。
流程:
- TM开启全局事务。
- 事务参与者与RM交互,注册分支事务。
- 事务参与者通过RM上报分支事务状态。
- TM向TC提交或回滚全局事务,事务一阶段结束。
- TC向RM发起二阶段提交或回滚。
Seata主推AT模式。
如何实现接口的幂等性
- 在接口中添加唯一ID,在请求接口时带上该唯一ID,并将该ID保存在Redis中并设置过期时间,后续该接口请求时如果还带有该ID来访问接口,则判定为重复请求。
- 服务端提供token,在调用服务端接口时先从服务端获取token,然后携带该token访问服务端接口,判定该token是否存在,如果存在则删除token并做出正确响应,如果不存在则判定为重复请求。
RabbitMQ架构设计
- Broker:接收和分发消息的应用,RabbitMQ Server就是Broker。
- Virtual Host:Broker包含了多个虚拟分组,每个虚拟分组里面包含了多个Exchange。
- Queue:保存消息的队列。
- Exchange:交换机,将消息按策略查询RoutingKey分发到消息Queue中。
- RoutingKey:路由键,消息转发地址。
- Binding:Exchange和Queue之间的虚拟连接,可以包含多个RoutingKey,是消息的分发依据。
生产者生产消息-> 投递给MQ-> MQ 将消息通过 Binding 关系 发送到绑定的 Queue -> 消费者消费消息。
RabbitMQ 六种模式
- 简单队列模式:生产者生产消息,消费者消费消息,消费者拿到消息后消息就自动从队列中删除。
- 工作队列模式:多个工作线程获取消息,通过轮询分发和不公平分发【消费者信道qos设置为1】将消息发送给工作线程处理,一个消息只能被一个工作线程处理。
当消费者丢失连接,导致无法发送ACK确认,RabbitMQ会将消费都未完全处理的消息重新加入队列,并分发给其它消费者处理,保证消息不丢失。
队列持久化:声明队列的时候,指定队列可持久化。
消息持久化:发消息时指定消息持久化。
- 发布订阅模式:
队列中的消息只能被消费一次,一个消息放在多个队列就可以被多个消费者获取。
- 路由模式
- 主题模式
- 发布确认模式
开启发布确认:chanel.confirmSelect()
1. 队列持久化:声明队列时,指定队列持久化。
2. 消息持久化:发消息指定消息持久化。
3. 发布确认:消息被保存在磁盘上后,MQ通知生产者消息已经被持久化。持久化失败会有异步通知。
RabbitMQ 如何确保消息发送?如何确保消息接收?
- 生产者
消息需要设置Confirm模式,所有信道发布的消息都会分配一个唯一ID,一但消息被投递到Queue中写入磁盘,信道会发送确认ack给生产者,如果RabbitMQ发生错误导致消息丢失会发送nack(未确认)给生产者,所有发送的消息都将被ack或nack一次。
确认模式是异步的,当消息接收成功或失败就会触发相应的回调。
- 消费者
消费者开启手动确认模式,RabbitMQ会等待消费者显示回发ack信号,才把消息删除,所以消费者在接到必须马上发送ack,如果RabbitMQ没有收到ACK,但是消费者与RabbitMQ连接的信道断开,信息也 会重新投递。
RabbitMQ事务消息
通过对信道设置实现事务消息:
chanel.txSelect() // 开启事务
chanel.txCommit() // 提交事务
chanel.txRollback() // 回滚事务
事务中的消息会被存到另一个队列中,当提交事务时才把所有消息发送到Queue中。
chanel.txSelect() broker 接到到响应 tx.Select-ok;
chanel.txCommit() broker 接到后响应 tx.Commit-ok;
chanel.txRollback() 接到到响应 tx.Rollback-ok;
使用使用会比消息确认机制多几次发送请求(如select、commit)和回复OK,所以性能会低一些。
消费者自动ack模式下,消费者不提交事务,消息也会被删除;手动ack模式下,消费者需要提交事务消息才会被删除。
RabbitMQ 死信队列和延时队列
- 死信队列
下面几种情况会使用消息成为"死信"消息:
1. 当消费方回复nack(`chanel.basicNack`或`channel.basicReject`)并且此时`requeue`属性被设置为`false`。
2. 消息的存活时间超过设置的TTL(Time to Live)时间。
3. 消息队列中的消息数量已经超过了最大队列长度
如果配置了死信队列,死信消息会被丢进死信队列,否则消息会被丢弃。
关于TTL
如果一条消息设置了TTL属性或进入了设置TTL属性的队列,那么这条消息在TTL设置的时间内没有被消费,则会成为"死信"消息。
如何操作:
先创建一个死信交换机(名为死信其实和普通交换机没任何区别)
创建一个死信队列(同样名为死信其实和普通队列没任何区别)
将死信交换机和死信队列进行绑定
新建普通队列,设置过期时间、指定死信交换机
- 延时队列
给消息设置TTL,并且不设置消费者消费该消息,当TTL时间过期后,消息进入死信队列,然后消费者来消费私信队列中的消息,达到延时队列的目的。
Spring Cloud
Spring Cloud 一套完整微服务解决方案,它集成了许多常用的优秀的微服务组件。
从前端应用请求开始设计到的微服务组件:
- Zuul:网关服务
- Eureka:服务注册与发现
- Config:配置中心
- Ribbon:服务调用、客户端负载均衡工具。
- OpenFeign:基于Ribbon,提供SpringMVC注解的方式来调用服务,就像调用本地方法一样。
- Hystrix:服务熔断和降级,基于线程池实现资源隔离,Hystrix会每一个HystrixCommand服务分配一个线程池。
- gateway:服务网关
- nacos:服务注册与发现
- nacos:配置中心
- OpenFeign:基于Ribbon,提供SpringMVC注解的方式来调用服务,就像调用本地方法一样。
- loadbalancer:Spring Cloud基于Ribbon封装的负载均衡组件(Ribbon不维护了)。
- sentinel:熔断、降级.
注:服务熔断和降级的区别?
服务降级:调用服务,服务出现超时、出错,就会快速响应降级方法。
服务熔断:当触发一定次数的失败后(宕机、超时),再调用服务就会触发熔断保护,快速响应降级方法。
服务降级每次都会调用原方法,服务熔断触发熔断后直接响应降级方法。
gateway 加 Nacos 实现动态路由
通过NacosFactory
创建一个ConfigService
添加监听器,监听指定的dataId也就是配置文件,监听到配置文件更新,就通过Gateway的RouteDefinitionWriter
的delete
和save
来更新路由配置。
简化:在Gateway中配置一个Nacos的监听器,通过dataId监听对应的配置文件,如果监听到配置文件发生变化,就调用gateway的RouteDefinitionWriter
来更新路由配置。
gateway 适配多语言
实现RequestGlobalFilter
在filter
中从ServerWebExchange
拿到请求头参数中的语言信息,然后拿到Response
,然后对Response解析,并修改多语言内容。
其它
Redis多路IO复用
MySQL主从、读写分离。
联合索引回表。
JMM,java内存模型
为什么要使用消息中间件,消息丢失怎么处理。
NIO和传统IO有什么区别。
Spring框架中Bean的生命周期。
IOC是什么,底层如何实现的。这么设计有什么好处。
Spring和Spring Boot的关系。Spring Boot的优点,如何自定义SpringBoot Starter。
左链接/右链接/全链接区别。
分库分表实现,如何保证数据一致性。
Http3次握手机制。
JVM分区/内存模型。
分布式缓存,分布式锁。