Java开发岗面试必备:高级Java开发工程师面试题解析

文章目录

Java基础

熟练掌握集合、Synchronized、ThreadLocal、AQS、线程池、JVM内存模型、类加载机制、双亲委派、垃圾回收算法、垃圾回收器、空间分配担保策略、可达性分析、强软弱虚引用、GC的过程、三色标记、跨代引用、内存泄漏与溢出、有JVM调优经验,如JVM调优目的原则、JVM调优常用的工具、排查步骤、各种GC场景下的优化。

集合

我想谈谈Java集合框架的根接口,其中包含了Collection和Map两个接口。Collection根接口又包含了List和Set两个子接口。

List接口的特点是元素有序且可重复,其中有三个实现类:ArrayList、Vector和LinkedList。ArrayList的底层是一个数组,线程不安全,查找快,增删慢。当我们使用ArrayList空参构造器创建对象时,底层会创建一个长度为10的数组,当我们向数组中添加第11个元素时,底层会进行扩容,扩容为原来的1.5倍。Vector是比ArrayList慢的古老实现类,其底层同样是一个数组,但线程安全。LinkedList的底层是使用双向链表,增删快但查找慢。

Set接口的特点是无序性和不可重复性,其中有三个实现类:HashSet、LinkedHashSet和TreeSet。HashSet的底层是一个HashMap,线程不安全,可容纳null,不能保证元素排列顺序。当向HashSet添加数据时,首先调用HashCode方法决定数据存放在数组中的位置,若该位置上有其他元素,则以链表的形式将该数据存在该位置上,若该链表长度达到8则将链表换成红黑树,以提高查找效率。LinkedHashSet继承了HashSet,底层实现和HashSet一样,可以按照元素添加的顺序进行遍历。TreeSet底层为红黑树,可以按照指定的元素进行排序。

Map的特点是键值对,其中key是无序、不可重复的,value是无序但可重复的,主要实现类有HashMap、LinkedHashMap、TreeMap和HashTable。HashMap的底层实现是一个数组(数组的类型是一个Node类型,Node中有key和value的属性,根据key的hashCode方法来决定Node存放的位置)+链表+红黑树(JDK1.8),线程不安全,可以存放null。LinkedHashMap继承了HashMap底层实现和HashMap一样,可以按照元素添加的顺序进行遍历,底层维护了一张链表用来记录元素添加的顺序。TreeMap可以对key中的元素按照指定的顺序进行排序。HashTable是线程安全的,不可容纳null,若map中有重复的key,后者的value会覆盖前者的value。

🎉 HashMap底层工作原理

在我的工作中,我经常使用HashMap,因此我对HashMap的底层知识有比较深入的了解。比如,当我们向HashMap中插入一个元素(k1,v1)时,它会先进行hash算法得到一个hash值,然后根据hash值映射到对应的内存地址,以此来获取key所对应的数据。如果该位置没有其它元素,它就会直接放入一个Node类型的数组中。默认情况下,HashMap的初始大小为16,负载因子为0.75。负载因子是一个介于0和1之间的浮点数,它决定了HashMap在扩容之前内部数组的填充度。因此,当元素加到12的时候,底层会进行扩容,扩容为原来的2倍。如果该位置已经有其它元素(k2,v2),那么HashMap会调用k1的equals方法和k2进行比较。如果返回值为true,说明二个元素是一样的,则使用v1替换v2。如果返回值为false,说明二个元素是不一样的,则会用链表的形式将(k1,v1)存放。但是,当链表中的数据较多时,查询的效率会下降。为了解决这个问题,在JDK1.8版本中HashMap进行了升级。当HashMap存储的数据满足链表长度超过8,数组长度大于64时,就会将链表替换成红黑树,以此来提高查找效率。

🎉 HashMap版本问题

我曾经了解到关于jdk1.7的hashmap存在着两个无法忽略的问题,其中第一个是在扩容时需要进行rehash操作,这个过程非常消耗时间和空间;第二个是当并发执行扩容操作时,会出现链表元素倒置的情况,从而导致环形链和数据丢失等问题,这些问题都会导致CPU利用率接近100%。而在JDK1.8中,HashMap的这两个问题得到了优化,首先在元素经过rehash之后,其位置要么是在原位置,要么是在原位置+原数组长度,这并不需要像旧版本的实现那样重新计算hash值,而只需要看看原来的hash值新增的那个bit是1还是0就好了。在数组的长度扩大到原来的2倍、4倍、8倍时,索引也会根据保留的二进制位上新增的1或0进行适当调整。其次,在JDK1.8中,发生哈希碰撞时,插入元素不再采用头插法,而是直接插入链表尾部,从而避免了环形链表的情况。不过在多线程环境下,还是会发生数据覆盖的情况,如果同时有线程A和线程B进行put操作,线程B在执行时已经插入了元素,而此时线程A获取到CPU时间片时会直接覆盖线程B插入的数据,从而导致数据覆盖和线程不安全的情况。

🎉 HashMap并发修改异常

在高并发场景下,使用HashMap可能会出现并发修改异常。这种情况是由于多线程争用修改造成的。当一个线程正在写入时,另一个线程也过来争抢,这就导致了线程写入过程被其他线程打断,从而导致数据不一致。针对这种情况,我了解到有四种解决方案。首先,可以使用HashTable,它是线程安全的,但也有缺点。它把所有相关操作都加上了锁,因此在竞争激烈的并发场景中性能会非常差。其次,可以使用工具类Collections.synchronizedMap(new HashMap<>());将HashMap转化成同步的,但是同样会有性能问题。第三种解决方案是使用写时复制(CopyOnWrite)技术。在往容器中加元素时,不会直接添加到当前容器中,而是先将当前容器的元素复制出来放到一个新的容器中,然后在新的容器中添加元素。写操作完毕后,再将原来容器的引用指向新的容器。这种方法可以进行并发的读,不需要加锁。但是在复制的过程中会占用较多的内存,并且不能保证数据的实时一致性。最后,使用ConcurrentHashMap则是一种比较推荐的解决方案。它使用了volatile,CAS等技术来减少锁竞争对性能的影响,避免了对全局加锁。在JDK1.7版本中,ConcurrentHashMap使用了分段锁技术,将数据分成一段一段的存储,并为每个段配备了锁。这样,当一个线程占用锁访问某一段数据时,其他段的数据也可以被其他线程访问,从而能够实现真正的并发访问。在JDK1.8版本中,ConcurrentHashMap内部使用了volatile来保证并发的可见性,并采用CAS来确保原子性,来解决了性能问题和数据一致性问题。

🎉 HashMap影响HashMap性能的因素

影响HashMap性能的两个关键因素:加载因子和初始容量。加载因子用于确定HashMap<K,V>中存储的数据量,并且默认加载因子为0.75。如果加载因子比较大,扩容发生的频率就会比较低,而浪费的空间会比较小,但是发生hash冲突的几率会比较大。举个例子,如果加载因子为1,HashMap长度为128,实际存储元素的数量在64至128之间,这个时间段发生hash冲突比较多,会影响性能。如果加载因子比较小,扩容发生的频率会比较高,浪费的空间也会比较多,但是发生hash冲突的几率会比较小。比如,如果加载因子为0.5,HashMap长度为128,当数量达到65的时候会触发扩容,扩容后为原理的256,256里面只存储了65个,浪费了。因此,我们可以取一个平均数0.75作为加载因子。另一个影响HashMap性能的关键因素是初始容量,它始终为2的n次方,可以是16、32、64等这样的数字。即使你传递的值是13,数组长度也会变成16,因为它会选择最近的2的n次方的数。在HashMap中,使用(hash值 &(长度-1))的二进制进行&运算来得到元素在数组中的下标。这样做可以保证运算得到的值可以落到数组的每一个下标上,避免了某些下标永远没有元素的情况。

举个例子,如果我有一个HashMap,容量为16,我的hash值是

11001110 11001111 00010011 11110001 (hash值)

然后我要进行&运算,运算的值是

00000000 00000000 00000000 00001111 (16-1的2进制)

这个值是16-1的2进制表示。然后,我就进行&运算了,得到的结果是

00000000 00000000 00000000 00000001

这个运算的意思是,我把hash值的2进制的后4位和1111进行比较,然后,我的hash值的后4位的范围是0000-1111之间,这样我就可以与上1111,最后的值就可以在0000-1111之间,也就是0-15之间。这样可以保证运算后的值可以落到数组的每一个下标中。如果数组长度不是2的幂次,后四位就不可能是1111,这样如果我用0000~1111的一个数和有可能不是1111的数进行&运算,那么就有可能导致数组的某些位下标永远不会有值,这样就无法保证运算后的值可以落在数组的每个下标上面。

🎉 HashMap使用优化

对于HashMap的使用优化,我个人有五点看法。首先,我建议使用短String、Integer这些类作为键,特别是String,因为它是不可变的,final的,已经重写了equals和hashCode方法,符合HashMap计算hashCode的不可变性要求,可以最大限度地减少碰撞的出现。其次,我建议不要使用for循环遍历Map,而是使用迭代器遍历entrySet,因为在各个数量级别迭代器遍历效率都比较高。第三,建议使用线程安全的ConcurrentHashMap来删除Map中的元素,或者在迭代器Iterator遍历时,使用迭代器iterator.remove()方法来删除元素。不可以使用for循环遍历删除,否则会产生并发修改异常CME。第四,建议在设定初始大小时要考虑加载因子的存在,最好估算存储的大小。可以使用Maps.newHashMapWithExpectedSize(预期大小)来创建一个HashMap,Guava会帮我们完成计算过程,同时考虑设定初始加载因子。最后,如果Map是长期存在而key又是无法预估的,那就可以适当加大初始大小,同时减少加载因子,降低冲突的机率。在长期存在的Map中,降低冲突概率和减少比较的次数更加重要。

🍊 Synchronized

Synchronized关键字在Java语言中是用来保证同一时刻只有一个线程执行被Synchronized修饰的代码块或方法。如果Synchronized修饰的是方法或对象,则该对象锁是非静态的,如果修饰的是静态方法或类,则该类锁是静态的,所有的该类对象共用一个锁。每个Java对象都有一把看不见的锁,也称为内部锁或Monitor锁。Synchronized的实现方式是基于进入和退出Monitor对象来实现方法和代码块同步。每个Java对象都是天生的Monitor,Monitor监视器对象存在于每个Java对象的对象头MarkWord里面,也就是存储指针的指向,Synchronized锁通过这种方式获取锁。

在JDK6之前,Synchronized加锁是通过对象内部的监视器锁来实现的,这种监视器锁的本质是依赖于底层的操作系统的Mutex Lock来实现。由于操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要比较长的时间。

JDK6版本及以后,Sun程序员发现大部分程序大多数时间都不会发生多个线程同时访问竞态资源的情况,大多数对象的加锁和解锁都是在特定的线程中完成,出现线程竞争锁的情况概率比较低,比例非常高,所以引入了偏向锁和轻量级锁。

从无锁到偏向锁的转换是一个多步骤的过程。第一步是检测MarkWord是否为可偏向状态,如果是偏向锁则为1,锁标识位为01。第二步是测试线程ID是否为当前线程ID,如果是,则直接执行同步代码块。如果不是,则进行CAS操作竞争锁,如果竞争成功,则将MarkWord的线程ID替换为当前线程ID。如果竞争失败,就启动偏向锁撤销并让线程在全局安全点阻塞,然后遍历线程栈查看是否有锁记录,如果有,则需要修复锁记录和MarkWord,让其变成无锁状态。最后恢复线程并将偏向锁状态改为0,偏向锁升级为轻量级锁。

对于轻量级锁升级,首先在栈帧中建立锁记录,存储锁对象目前的MarkWord的拷贝。这是为了在申请对象锁时可以以该值作为CAS的比较条件,并在升级为重量级锁时判定该锁是否被其他线程申请过。成功拷贝后,使用CAS操作将对象头MarkWord替换为指向锁记录的指针,并将锁记录空间里的owner指针指向加锁的对象。如果更新成功,当前线程则拥有该对象的锁,对象MarkWord的锁标志位设置为"00",即表示此对象处于轻量级锁定状态。如果更新操作失败,虚拟机将检查对象MarkWord中的Lock Word是否指向当前线程的栈帧,如果是,则当前线程已经拥有该对象的锁,直接进入同步块继续执行。如果不是,说明多个线程竞争锁,进入自旋。如果自旋失败,轻量级锁将转换为重量级锁,锁标志的状态值变为"10",MarkWord中存储的是指向重量级锁的指针,当前线程以及后面等待锁的线程也要进入阻塞状态。最后,如果新线程过来竞争锁,锁将升级为重量级锁。

当一个线程需要获取某个锁时,如果该锁已经被其他线程占用,我们可以使用自旋锁来避免线程阻塞或者睡眠。自旋锁是一种策略,它不能替代阻塞,但是它可以避免线程切换带来的开销。使用自旋锁,线程会一直循环检测锁是否被释放,直到获取到锁。但是使用自旋锁也有一些坏处,频繁的自旋操作会占用CPU处理器的时间,因此自旋锁适用于锁保护的临界区很小的情况,如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好。但是自旋的次数必须要有一个限度,如果自旋超过了限度仍然没有获取到锁,就应该被挂起。由于程序锁的状况是不可预估的,JDK1.6引入了自适应的自旋锁,以根据不同的程序锁状态自适应地调整自旋的次数,提高自旋的效率并减少CPU的资源浪费。为了开启自旋锁,我们可以使用参数--XX:+UseSpinning。并且可以使用--XX:PreBlockSpin来修改自旋次数,默认值是10次。

当一个线程在等锁时,它会不停地自旋。事实上,底层就是一个while循环。当自旋的线程达到CPU核数的1/2时,就会升级为重量级锁。这时,锁标志被置为10,MarkWord中的指针指向重量级的monitor,所有没有获取到锁的线程都会被阻塞。Synchronized实际上是通过对象内部的监视器锁(Monitor)来实现的。这个监视器锁本质上是依赖于底层的操作系统的MutexLock来实现的。操作系统实现线程之间的切换需要从用户态转换到核心态,状态之间的转换需要比较长的时间。这就是为什么Synchronized效率低的原因。我们称这种依赖于操作系统MutexLock所实现的锁为"重量级锁"。重量级锁撤销之后是无锁状态。撤销锁之后会清除创建的monitor对象并修改markword,这个过程需要一段时间。Monitor对象是通过GC来清除的。GC清除掉monitor对象之后,就会撤销为无锁状态。

Synchronized

定义

Synchronized能保证同一时刻被Synchronized修饰的代码最多只有1个线程执行。

synchronized如果加在方法上/对象上,那么,它作用的对象是非静态的,它取得的锁是对象锁;

synchronized如果作用的对象是一个静态方法或一个类,它取到的锁是类锁,这个类所有的对象用的是同一把锁。

JDK6以前

Synchronized加锁是通过对象内部的监视器锁来实现的,监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的,操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要比较长的时间。

Sun程序员发现大部分程序大多数时间都不会发生多个线程同时访问竞态资源的情况,大多数对象的加锁和解锁都是在特定的线程中完成,出现线程竞争锁的情况概率比较低,比例非常高,所以引入了偏向锁和轻量级锁。

对象从无锁到偏向锁转化的过程

第一步,检测MarkWord是否为可偏向状态,是偏向锁是1,锁标识位是01。

第二步,如果是可偏向状态,测试线程ID是不是当前线程ID。如果是,就直接执行同步代码块。

第三步,如果测试线程ID不是当前线程ID,且当前对象的MarkWord标识为可偏向状态,这说明该对象已经被其他线程获取过锁,不能偏向于当前线程,就需要通过CAS操作竞争锁,竞争成功,就把MarkWord的线程ID替换为当前线程ID,同时执行同步代码块。

第四步,假设线程B来CAS失败了,需要将当前对象的偏向锁撤销,然后再进行锁竞争,这个时候JVM后台线程会启动偏向锁撤销过程。

偏向锁撤销触发通常有二种情况:当在对象头的Epoch字段计数到一定次数时会触发偏向锁撤销或者有多个线程尝试竞争该对象的锁,都失败了,也会触发偏向锁的撤销。

  • 当有多个线程尝试获取同一个对象的锁时,通常只有一个线程能够获取成功,而其他线程则需要进入等待状态。当这个对象曾经被偏向锁优化过,且当前正在尝试获取锁的线程不是偏向线程时,如果该线程获取锁成功,JVM会撤销该对象的偏向锁状态,并重新获取锁。这个过程不需要等待其他线程都失败,只要有一个线程成功获取锁即可触发偏向锁的撤销。
  • 在Java对象的内存布局中,有一个叫做对象头的部分,其中的Epoch字段是为了解决ABA问题而设置的。与此同时,当一个对象从偏向锁升级为轻量级锁或重量级锁时,对象的标记字(MarkWord)中保存的线程ID可能会发生变化。为了能够判断一个线程是否是因为ABA问题导致的线程变化,会将Epoch字段的值加一来表示变化次数。因此,在线程之间竞争锁时,可以通过比较Epoch字段的值是否相同来判断锁是否发生了变化。如果发生了变化,说明对象的状态已经被其他线程修改,需要重新竞争锁。与此同时,这个Epoch字段并没有直接关系到偏向锁撤销的过程。
  • ABA问题指的是在多线程编程中,当多个线程对一个内存位置进行读取和修改时,可能会出现一种情况:最初的值被读取,然后被修改为新的值A,再次被修改为原来的值,也就是回到了最初的值B,但此时中间可能有其他线程对这个内存位置进行了修改,所以此时相对于最初的值B,这个内存位置已经发生了变化,无论是A还是其他值都与最初的B不同。在这种情况下,如果仅仅判断最终的值是否等于B,可能出现问题,需要使用其他机制来判断这个内存位置是否发生过变化。
  • 偏向锁撤销不一定会挂起所有持有偏向锁的线程,只有在线程竞争锁时才会挂起线程。如果没有其他线程竞争锁,偏向锁撤销的过程只会涉及到当前对象的MarkWord,不会影响到其他线程。

撤销偏向锁的过程会挂起所有持有偏向锁的线程(例如线程A),并清除它们的MarkWord中的偏向锁信息(标记位、偏向线程ID、Epoch次数)。然后遍历偏向锁的对象所在的线程的栈,查找锁对象的锁记录,将那些已经被访问过的记录清除,以表明这些线程都不再持有该锁。

在撤销偏向锁的过程中,需要清除那些曾经持有该偏向锁对象的线程的锁记录。这是因为在偏向锁状态下,持有锁的线程会在对象头中记录一个标记位和持有该锁的线程ID。而在撤销偏向锁的过程中,需要清除这些锁记录,因为它们已经不再持有该锁,以便其他线程可以重新争夺锁的所有权。

第五步,完成偏向锁撤销后,持有偏向锁的线程不会被挂起,而是会继续执行同步代码块。线程A尝试获取该锁,如果获取成功,就可以继续执行同步代码块了,当线程A尝试获取该锁失败时,视情况继续进行自旋或者进行阻塞等待,导致进一步升级为轻量级锁或重量级锁。

安全点是jvm为了保证在垃圾回收的过程中引用关系不会发生变化,设置的安全状态,在这个状态上会暂停所有线程工作。一般有循环的末尾,方法临返回前,调用方法的call指令后,可能抛异常的位置,这些位置都可以算是安全点。

轻量级锁升级

轻量级锁升级过程是,在当前线程的栈帧中建立一个名为锁记录的空间,用于存储锁对象目前的MarkWord的拷贝,拷贝无锁状态对象头中的MarkWord复制到锁记录中。

  • 这么做是因为在申请对象锁时,需要以该值作为CAS的比较条件。
  • 同时在升级到重量级锁的时候,能通过这个比较,判定是否在持有锁的过程中,这个锁被其他线程申请过,如果被其他线程申请了,在释放锁的时候要唤醒被挂起的线程。
  • 无锁的markword中可能存有hashCode,锁撤销之后必须恢复,这个markword要用于锁撤销后的还原。如果轻量级锁解锁为无锁状态,直接将拷贝的markword CAS修改到锁对象的markword里面就可以了。

拷贝成功后,虚拟机将使用CAS操作把对象中对象头MarkWord替换为指向锁记录的指针,然后把锁记录空间里的owner指针指向加锁的对象。

这个过程的目的是为了实现轻量级锁的互斥访问。CAS操作的作用是将对象头MarkWord指针指向锁记录空间,从而表示当前线程持有这个对象的锁。锁记录空间里的owner指针指向加锁的对象,是为了在释放锁的时候,能够知道哪个对象需要进行通知,从而唤醒被挂起等待锁的线程。同时,轻量级锁的升级过程也可以通过锁记录空间来判断是否在持有锁的过程中,这个锁被其他线程申请过,如果被其他线程申请了,在释放锁的时候要唤醒被挂起的线程,从而保证多个线程对同一个对象的访问是互斥的。

如果这个更新动作成功了,那么当前线程就拥有了该对象的锁,并且对象MarkWord的锁标志位设置为"00",即表示此对象处于轻量级锁定状态。

如果这个更新操作失败了,虚拟机首先会检查对象MarkWord中的Lock Word是否指向当前线程的栈帧。

Lock Word是一个对象头的一部分,用于实现Java对象的锁定。当一个线程获取了对象的锁时,Lock Word就会被设置为指向该线程的栈帧,表示这个对象被该线程持有了锁。其他线程如果要获取该对象的锁,会检查Lock Word中的锁标志是否为0,如果为0则表示该对象没有被锁定,可以获取锁。如果锁标志为1,则表示对象已经被锁定,其他线程必须等待锁的释放。

如果是,就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。如果不是说明多个线程竞争锁,进入自旋,若自旋结束时仍未获得锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为"10",MarkWord中存储的就是指向重量级锁(互斥量)的指针,当前线程以及后面等待锁的线程也要进入阻塞状态。

当锁升级为轻量级锁之后,如果依然有新线程过来竞争锁,首先新线程会自旋尝试获取锁,尝试到一定次数(默认10次)依然没有拿到,锁就会升级成重量级锁。一般来说,同步代码块内的代码应该很快就执行结束,这时候线程B自旋一段时间是很容易拿到锁的,但是如果不巧,没拿到,自旋其实就是死循环,很耗CPU的,因此就直接转成重量级锁咯,这样就不用了线程一直自旋了。

自旋锁

自旋锁不是一种锁状态,而是一种策略。线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作。

引入自旋锁,当一个线程尝试获取某个锁时,如果该锁已被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或睡眠状态。自旋等待不能替代阻塞,虽然它可以避免线程切换带来的开销,但是它占用了CPU处理器的时间。

自旋锁适用于锁保护的临界区很小的情况,临界区很小的话,锁占用的时间就很短。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好。

自旋的次数必须要有一个限度,如果自旋超过了定义的限度仍然没有获取到锁,就应该被挂起。但是这个限度不能固定,程序锁的状况是不可预估的,所以JDK1.6引入自适应的自旋锁,线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。如果对于某个锁,很少有自旋能够成功,那么在以后要或者这个锁的时候自旋的次数会减少,甚至省略掉自旋过程,以免浪费处理器资源。

通过--XX:+UseSpinning参数来开启自旋(JDK1.6之前默认关闭自旋)。

通过--XX:PreBlockSpin修改自旋次数,默认值是10次。

重量级锁

当一个线程在等锁时会不停的自旋(底层就是一个while循环),当自旋的线程达到CPU核数的1/2时,就会升级为重量级锁。

将锁标志为置为10,将MarkWord中指针指向重量级的monitor,阻塞所有没有获取到锁的线程。

Synchronized是通过对象内部的监视器锁(Monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的MutexLock来实现的,操作系统实现线程之间的切换这就需要从用户态转换到核心态,状态之间的转换需要比较长的时间,这就是为什么Synchronized效率低的原因,这种依赖于操作系统MutexLock所实现的锁我们称之为"重量级锁"。

重量级锁的加锁-等待-撤销流程:

曾经获得过锁的线程,被唤醒后,优先得到锁。

举个例子,假设有A,B,C三个线程依次进入synchronized区,并且A已经膨胀成重量级锁。如果有一个线程 a 先进入 synchronized , 但是调用了 wait释放锁,这是线程 b 进入了 synchronized,b还在synchronized中执行,c线程又进来了。此时 a 在 wait_set ,b 不在任何队列,c 在 cxq_list ,假如 b 调用 notify唤醒线程,会把 a 插到 c 前面,也就是 b 退出synchronized的时候,会唤醒 a,a退出之后再唤醒 c。

重量级锁撤销之后是无锁状态,撤销锁之后会清除创建的monitor对象并修改markword,这个过程需要一段时间。Monitor对象是通过GC来清除的。GC清除掉monitor对象之后,就会撤销为无锁状态。

ThreadLocal

ThreadLocal是Java中的一个类,它可以实现线程间的数据隔离。这意味着每个线程都可以在自己的ThreadLocal对象内保存数据,从而避免了多个线程之间对数据的共享。相比之下,Synchronized则用于线程间的数据共享,它通过锁的机制来确保在某一时间点只有一个线程能够访问共享的数据。ThreadLocal的底层实现方式是在Thread类中嵌入了一个ThreadLocalMap。在这个ThreadLocalMap中,每个ThreadLocal对象都有一个threadLocalHashCode。这个threadLocalHashCode是用来在ThreadLocalMap中定位到对应的位置的。当数据存储时,ThreadLocalMap会根据threadLocalHashCode找到对应的位置,并在该位置上存储一个Entry对象。这个Entry对象中,key为ThreadLocal对象,value则为对应的数据。在获取数据时,同样会根据threadLocalHashCode找到对应的位置,然后判断该位置上的Entry对象中的key是否与ThreadLocal对象相同。如果相同,则返回对应的value。这种方式可以保证每个线程都可以拥有自己的数据副本,从而实现线程间的数据隔离。在实际应用中,ThreadLocal经常被用来保存一些线程相关的信息,例如用户信息、语言环境等。这样可以让每个线程都能独立地处理自己的相关信息,而不会受到其他线程的影响。

AQS

AQS的核心思想是共享状态的管理,它通过一个int型的volatile变量来描述同步状态,同时维护一个FIFO队列,用来存放等待线程。在AQS的实现中,同步状态的修改和FIFO队列的维护是通过CAS(Compare-And-Swap)操作实现的。

当一个线程需要获取某个同步资源时,它会先尝试通过CAS操作修改同步状态,如果成功获取锁,则直接返回。

获取锁的方法取决于同步资源的类型。如果是独占资源,那么只有一个线程可以获取锁,其他线程需要等待独占锁释放。此时,获取锁的方法是通过CAS操作修改同步状态,如果成功获取锁,则返回;否则被封装为Node节点,加入到FIFO队列中等待。

如果是共享资源,多个线程可以同时获取锁,这就需要采用读写锁的机制。通过读锁可以实现多个线程同时对同一共享资源进行读操作,而写锁则是独占锁,只允许一个线程进行写操作。获取共享锁的方法是通过尝试通过CAS操作修改同步状态,并判断当前状态是否允许获取共享锁,如果成功获取锁,则返回;否则被封装为Node节点,加入到FIFO队列中等待。

如果CAS操作失败,则说明当前线程没有成功获取锁,它就会被封装为一个Node节点,并加入到FIFO队列的尾部。

如果是独占锁,节点会被加入到一个同步队列中,每个节点都有一个前驱节点和后继节点,通过这些节点连接起来形成一个FIFO队列。加入队列的方法是通过自旋操作,不断尝试将当前节点加入到队列中。如果是共享锁,则会维护两个FIFO队列,一个用来存放读锁的等待线程,另一个用来存放写锁的等待线程。

当同步状态的持有者释放锁时,它会尝试唤醒队列中的第一个线程。被唤醒的线程会再次尝试通过CAS操作获取锁,直到它成功获取锁为止。

在等待队列中,节点会有前驱和后继节点。当一个节点被唤醒并成功获取锁后,它会检查自己的后继节点是否处于等待状态。如果是,当前线程会尝试通过CAS操作获取同步状态,并将后继节点从等待队列中移除。如果获取同步状态的操作失败,说明后继节点已经被其他线程获取了锁,当前线程需要继续等待。否则,当前线程会继续尝试获取锁,如果获取成功,则返回;否则继续封装为节点加入到FIFO队列中等待。

线程池

底层运行原理

线程池就是控制运行的线程数量,处理过程中将任务放到队列,然后在线程创建后启动这些任务,如果线程数量超出了最大数量就排队等候,等其他线程执行完毕再从队列中取出任务执行。

线程池相当于银行网点,常驻核心数相当于今日当值窗口,线程池能够同时执行的最大线程数相当于银行所有的窗口,任务队列相当于银行的候客区,当今日当值窗口满了,多出来的客户去候客区等待,当候客区满了,银行加开窗口,候客区先来的客户去加班窗口,当银行所有的窗口满了,其他客户在候客区等待,同时拒绝其他客户进入银行。当用户少了,加班的窗口等待时间(相当于多余线程存活的时间)(等待时间的单位相当于unit参数)假设超过一个小时还是没有人来,就取消加班的窗口。

七大核心参数

底层在创建线程池的时候有七个参数:核心线程数,同时执行的最大线程数,多余线程存活时间,单位时间秒,任务队列,默认线程工厂,拒绝策略

maximumPoolsize:同时执行的最大线程数

keepAliveTime:多余线程存活时间,当前线程池数量超过核心线程数时,当前空闲时间达到多余线程存活时间的值的时候,多余空闲线程会被销毁到只剩核心线程数为止

unit:多余线程存活时间的单位

workQueue:任务队列,被提交但尚未被执行的任务

threadFactory:生成线程池的线程工厂

handler:拒绝策略,当队列满了并且工作线程数量大于线程池的最大线程数时,提供拒绝策略。

如何合理的配置核心线程数?

对于CPU密集型任务,由于CPU密集型任务的性质,导致CPU的使用率很高,如果线程池中的核心线程数量过多,会增加上下文切换的次数,带来额外的开销。因此,考虑到CPU密集型任务因为某些原因而暂停,这个时候有额外的线程能确保CPU这个时刻不会浪费,还可以增加一个CPU上下文切换。一般情况下:线程池的核心线程数量等于CPU核心数+1。例如需要大量的计算,视频渲染啊,仿真啊之类的。这个时候CPU就卯足了劲在运行,这个时候切换线程,反而浪费了切换的时间,效率不高。打个比方,你的大脑是CPU,你本来就在一本心思地写作业,多线程这时候就是要你写会作业,然后立刻敲一会代码,然后在P个图,然后在看个视频,然后再切换回作业。emmmm,过程中你还需要切换(收起来作业,拿出电脑,打开VS...)那你的作业怕是要写到挂科。这个时候你就该一门心思地写作业。

对于I/O密集型任务,由于I/O密集型任务CPU使用率并不是很高,可以让CPU在等待I/O操作的时去处理别的任务,充分利用CPU。一般情况下:线程的核心线程数等于2*CPU核心数。例如你需要陪小姐姐或者小哥哥聊天,还需要下载一个VS,还需要看博客。打个比方,小姐姐给你发消息了,回一下她,然后呢?她给你回消息肯定需要时间,这个时候你就可以搜索VS的网站,先下安装包,然后一看,哎呦,她还没给你回消息,然后看会自己的博客。小姐姐终于回你了,你回一下她,接着看我的博客,这就是类似于IO密集型。你可以在不同的"不烧脑"的工作之间切换,来达到更高的效率。而不是小姐姐不回我的信息,我就干等,啥都不干,就等,这个效率可想而知,也许,小姐姐根本就不会回复你。

对于混合型任务,由于包含2种类型的任务,故混合型任务的线程数与线程时间有关。在某种特定的情况下还可以将任务分为I/O密集型任务和CPU密集型任务,分别让不同的线程池去处理。一般情况下:线程池的核心线程数=(线程等待时间/线程CPU时间+1)*CPU核心数;

并发高、业务执行时间长,解决这种类型任务的关键不在于线程池而在于整体架构的设计,看看这些业务里面某些数据是否能做缓存是第一步,我们的项目使用的时redis作为缓存(这类非关系型数据库还是挺好的)。增加服务器是第二步(一般政府项目的首先,因为不用对项目技术做大改动,求一个稳,但前提是资金充足),至于线程池的设置,设置参考 2 。最后,业务执行时间长的问题,也可能需要分析一下,看看能不能使用中间件(任务时间过长的可以考虑拆分逻辑放入队列等操作)对任务进行拆分和解耦。

拒绝策略

第一种拒绝策略:AbortPolicy:超出最大线程数,直接抛出RejectedExecutionException异常阻止系统正常运行。可以感知到任务被拒绝了,于是你便可以根据业务逻辑选择重试或者放弃提交等策略。

第二种拒绝策略:该策略既不会抛弃任务,也不会抛出异常,而是将某些任务回退到调用者,相当于当线程池无能力处理当前任务时,会将这个任务的执行权交予提交任务的线程来执行,也就是谁提交谁负责,从而降低新任务的流量。(谁调用了你,到达最大线程数时,你回去找调用你的人,然后听从调用你的人安排)(超出的我们能办的给你办,不能办的给你回退 )这样的话提交的任务就不会被丢弃而造成业务损失,如果任务比较耗时,那么这段时间内提交任务的线程也会处于忙碌状态而无法继续提交任务,这样也就减缓了任务的提交速度,这相当于一个负反馈,也有利于线程池中的线程来消化任务。这种策略算是最完善的相对于其他三个。

第三拒绝策略:DiscardOldestPolicy:抛弃队列中等待最久的任务,也就是它丢弃的是队列中的头节点,然后把当前任务加入队列中尝试再次提交当前任务。

第四种拒绝策略:DiscardPolicy:直接丢弃任务,不予任何处理也不抛异常,当任务提交时直接将刚提交的任务丢弃,而且不会给与任何提示通知。

实际创建线程池

java.util.concurrent 包里提供的 Executors 也可以用来创建线程池

  • newSingleThreadExecutos 单线程线程池,也就是线程池只有一个任务,这个我偶尔用一用
  • newFixedThreadPool(int nThreads) 固定大小线程的线程池
  • newCachedThreadPool() 无界线程池,这个就是无论多少任务,都创建线程来运行,所以队列相当于没用。

在实际使用的时候,选择线程池的时候尽量不用JDK提供的三种常见的创建方式

第一是 Executors 提供的线程池使用场景很有限,一般场景很难用到

第二他们也都是通过 ThreadPoolExecutor 创建的线程池,我直接用 ThreadPoolExecutor 创建线程池,可以理解原理,灵活度更高。

第三因为它的底层队列是Linked这个接近于无界,非常大,这样会堆积大量的请求,从而导致OOM,阿里巴巴开发手册推荐我们使用ThreadPoolExecutor去创建线程池。

JVM内存模型

JDK 1.6:有永久代,静态变量存放在永久代上。

JDK 1.7:有永久代,但已经把字符串常量池、静态变量,存放在堆上。逐渐的减少永久代的使用。

JDK 1.8:无永久代,运行时常量池、类常量池,都保存在元数据区,也就是常说的元空间。但字符串常量池仍然存放在堆上。

每一个方法在执行的同时,都会创建出一个栈帧,用于存放局部变量表、操作数栈、动态链接、方法出口、线程等信息。方法从调用到执行完成,都对应着栈帧从虚拟机中入栈和出栈的过程。最终,栈帧会随着方法的创建到结束而销毁。

在 JDK 1.8 之后就不在堆上分配方法区了,元空间从虚拟机Java堆中转移到本地内存,默认情况下,元空间的大小仅受本地内存的限制,说白了也就是以后不会因为永久代空间不够而抛出OOM异常出现了。jdk1.8以前版本的 class和JAR包数据存储在 PermGen下面 ,PermGen 大小是固定的,而且项目之间无法共用,公有的 class,所以比较容易出现OOM异常。

类加载机制、双亲委派

第一步,加载,一个Java源文件进行编译之后,成为一个class字节码文件存储在磁盘上面,这个时候jvm需要读取这个字节码文件,通过通过IO流读取字节码文件,这一步就是加载。

类加载器将.class文件加载到JVM,首先是看当前类是不是使用自定义加载类加载的,如果不是,就委派应用类加载器加载,如果有加载过这个class文件,那就不用再加载了。如果没有,那么会拿到父加载器,然后调用父加载器的loadClass方法。父类的扩展类加载器同理也会先检查自己是不是已经加载过,如果没有再往上,看看启动类加载器。到启动类加载器,已经没有父加载器了,这时候开始考虑自己是否能加载了,如果自己加载不了,就会下沉到子加载器去加载,一直到最底层,如果没有任何加载器能加载,就会抛出ClassNotFoundException找不到类异常,这就是所谓的双亲委派机制。

这种机制可以避免,同路径下的同文件名的类,比如,自己写了一个java.lang.obejct,这个类和jdk里面的object路径相同,文件名也一样,这个时候,如果不使用双亲委派机制的话,就会出现不知道使用哪个类的情况,而使用了双亲委派机制,它就委派给父类加载器就找这个文件是不是被加载过,从而避免了上面这种情况的发生。

第二步,验证,JVM读到文件也不是直接运行,还需要校验加载进来的字节码文件是不是符合JVM规范

验证的第一步就是文件的格式验证,验证class文件里面的魔数和主次版本号,发现它是一个jvm可以支持的class文件并且它的主次版本号符合兼容性要求,所以验证通过。

第三步,加载,它会将class文件这个二进制静态文件转化到方法区里面,转化为方法区的时候,会有一个结构的调整,将静态的存储文件转化为运行时数据区,这个转化等于说又回到了加载。

接着到了方法区的运行时数据区以后,在java堆内存里面生成一个当前类的class对象,作为方法区里面这个类,被各种访问的一个入口。比如说object类,它是所有类都继承它,访问它,所以它也需要一个被各种类访问的入口。object类先加载,加载完成之后,它经过这一系列的操作,把自己java.lang.object放到这个堆里面,要让其他的类进行访问,这个也是加载。

第四步,继续验证,接着元数据验证,它会对字节码描述的信息进行语义分析,比如:这个类是不是有父类,是不是实现了父类的抽象方法,是不是重写了父类的final方法,是不是继承了被final修饰的类等等。

然后字节码验证,通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的,比如:操作数栈的数据类型与指令代码序列是不是可以配合工作,方法中的类型转换是不是有效等等。

最后符号引用验证:确保解析动作可以正确执行,比如说:通过符号引用是不是可以找到对应的类和方法,符号引用中类、属性、方法的访问性是不是能被当前类访问等,验证完成之后,需要做准备。

第五步,分配内存空间,准备就是给类的静态变量分配内存,并赋予默认值。我们的类里,可能会包含一些静态变量, 比如说public static int a = 12; 得给a这个变量分配个默认值 0,再比如public static User user = new User(); 给 static的变量User分配内存,并赋默认值null。如果是final修饰的常量,就不需要给默认值了,直接赋值就可以了。

第六步,解析,解析就是将符号引用变为直接引用,该阶段会把一些静态方法替换为指向数据储存在内存中的指针或者句柄,也就是所谓的直接引用,这个就是静态链接过程,是在初始化之前完成。有静态链接就有动态链接,动态链接是在程序运行期间完成将符号引用替换为直接引用,比如静态方法里面有个方法,在运行的时候,方法是存放在常量池中的符号,运行到这个符号,就是找这个符号对应的方法区,因为代码的指令是加载到方法区里面去的,最后把方法对应代码的地址放到栈帧中的动态链接里。

  • 编译器中的解析阶段其目的是将符号引用(如变量、函数等名字)转换成直接引用(如指向内存中的地址),从而使程序能够正确地执行。
  • 直接引用是指程序中直接使用的方法或数据在内存中对应的地址。在程序编译期间,编译器将所有的方法和数据按照一定的规则映射到内存中的不同位置,生成可执行文件。在程序运行期间,程序通过直接引用来访问这些方法和数据,直接引用一般是一个绝对地址或偏移量。因此,在程序运行时,操作系统需要将程序中的符号引用转换成直接引用,以正确地访问方法和数据。
  • 符号引用是指程序中使用的方法或数据的标识符,不是直接的内存地址。在编译期间,编译器不能确定符号引用对应的具体地址,因为这些方法和数据在运行时可能会被加载到不同的内存地址上。因此,在编译期间,编译器将符号引用记录在符号表中,并在链接期间将符号引用解析为直接引用。在操作系统加载程序之前,链接器会根据符号表中的信息,找到并解析程序中所有的符号引用,将它们转换为直接引用。
  • 静态链接是指将程序中所有需要用到的代码和数据在编译时就全部链接成一个可执行文件的过程。在静态链接过程中,编译器会将静态库中的函数和变量复制到可执行文件中,形成一个完整的可执行文件。在程序运行时,所有的代码和数据都存在于内存中,程序不需要再依赖外部库文件或动态链接库。静态链接的缺点是可执行文件较大,不易于维护和更新。
  • 动态链接是一个程序运行时(运行期间)连接目标文件模块的过程,将需要的代码添加到进程的地址空间中,并将不同的模块组合在一起,使它们能够相互调用。在动态链接的过程中,程序中的符号引用被动态解析为直接引用,这样程序才能正确地访问方法和数据。动态链接的好处是减小了程序的大小,同时也方便了程序的更新和维护。常见的动态链接库(DLL)就是使用动态链接方式加载的。
  • 在静态链接过程中,一些静态方法会被替换成直接引用,这个过程在程序初始化之前完成。动态链接是在程序运行期间完成,它会将符号引用替换成直接引用,使程序能够正确地访问方法和数据。具体来说,动态链接会将符号引用对应的方法的代码地址放到栈帧中的动态链接里,从而实现符号引用到直接引用的转换。

第七步,初始化了,初始化就是对类的静态变量初始化为指定的值并且会执行静态代码块。比如准备阶段的public static final int a = 12;这个变量,就是准备阶段给static变量a赋了默认值0,这一步就该把12赋值给它了。还有static的User public static User user = new User(); 把User进行实例化。

第八步,就是使用和卸载了,到此整个加载流程就走完了。

垃圾回收算法、垃圾回收器、空间分配担保策略

垃圾回收器有多个,先说新生代的三个垃圾回收器,serial,parnew,parallel scavenge,然后再说老年代的serial old,parallel old,cms,最后在说一下新生代和老年代都使用的垃圾回收器G1吧。

Serial

Serial是新生代下使用复制算法,单线程运行的垃圾回收器,简单高效,没有线程交互开销,专注于GC,这个垃圾回收器工作的时候会将所有应用线程全部冻结,而且是单核cpu,所以基本不会考虑使用它。

ParNew

ParNew是新生代下使用复制算法,多线程运行的垃圾回收器,可以并行并发GC,和serial对比,除了多核cpu并行gc其他基本相同。

Parallel scavenge

Parallel scavenge也是新生代下使用复制算法,可以进行吞吐量控制的多线程回收器,主要关注吞吐量,通过吞吐量的设置控制停顿时间,适应不同的场景。可以发现新生代的垃圾回收器都使用,复制算法进行gc。

复制算法

新生代中每次垃圾回收都要回收大部分对象,所以为了避免内存碎片化的缺陷,这个算法按内存容量将内存划分为大小相等的两块,每次只使用其中一块,当这一块存活区内存满后将gc之后还存活的对象复制到另一块存活区上去,把已使用的内存清掉。

分代收集算法

按照分代收集算法的思想,把应用程序可用的堆空间分为年轻代,老年代,永久代,然后年轻代有被分为Eden区和二个Survivor存活区,这个比例又可以分为8比1比1。当第一次eden区发生minor gc,会把存活的对象复制到其中的一个Survivor区,然后eden区继续放对象,直到触发gc,会把eden区和之前存放对象的Survivor区一起gc,二个区存活下来的对象,复制到另一个空的Survivor里面,这二个区就清空,然后将二个存活区角色互换。

进入老年代的几种情况

当对象在Survivor区躲过一次GC 后,年龄就会+1,存活的对象在二个Survivor区不停的移动,默认情况下年龄到达15的对象会被移到老生代中,这是对象进入到老年代的第一种情况。

这里就会有个问题,JVM分代年龄为什么是15次?

一个对象的GC年龄,是存储在对象头里面的,一个Java对象在JVM内存中的布局由三个部分组成,分别是对象头、实例数据、对齐填充。而对象头里面有4个bit位来存储GC年龄。

4个bit位能够存储的最大数值是15,所以从这个角度来说,JVM分代年龄之所以设置成15次是因为它最大能够存储的数值就是15。虽然JVM提供了参数来设置分代年龄的大小,但是这个大小不能超过15。从设计角度来看,当一个对象触发了最大值15次gc,还没有办法被回收,就只能移动到old generation了。另外,设计者还引入了动态对象年龄判断的方式来决定把对象转移到old generation,也就是说不管这个对象的gc年龄是否达到了15次,只要满足动态年龄判断的依据,也同样会转移到old generation。

第二种情况就是,创建了一个很大的对象,这个对象的大小超过了jvm里面的一个参数max tenuring thread hold值,这个时候不会创建在eden区,新对象直接进入老年代。

第三种情况,如果在Survivor区里面,同一年龄的所有对象大小的总和大于Survivor区大小的一半,年龄大于等于这个年龄对象的,就可以直接进入老年代,举个例子,存活区只能容纳5个对象,有五个对象,1岁,2岁,2岁,2岁,3岁,3个2岁的对象占了存活区空间的5分之三,大于这个空间的一半了,这个时候大于等于2岁的对象,需要移动到老年代里面,也就是3个2岁的,一个3岁的对象移动到老年代里面。

空间分配担保

第四种情况就是eden区存活的对象,超过了存活区的大小,会直接进入老年代里面。另外在发生minor gc之前,必须检查老年代最大可用连续空间,是不是大于新生代所有对象的总空间,如果大于,这一次的minor gc可以确保是安全的,如果不成立,jvm会检查自己的handlepromotionfailure这个值是true还是false。true表示运行担保失败,false则表示不允许担保失败。如果允许,就会检查老年代最大可用连续空间是不是大于历次晋升到老年代平均对象大小,如果大于就尝试一次有风险的minorgc,如果小于或者不允许担保失败,那就直接进行fgc了。

举个例子,在minorgc发生之前,年轻代里面有1g的对象,这个时候,老年代瑟瑟发抖,jvm为了安慰这个老年代,它在minor gc之前,检查一下老年代最大可用连续空间,假设老年代最大可用连续空间是2g,jvm就会拍拍老年代的肩膀说,放心,哪怕年轻代里面这1g的对象全部给你,你也吃的下,你的空间非常充足,这个时候,老年代就放心了。

但是大部分情况下,在minor gc发生之前,jvm检查完老年代最大可用连续空间以后,发现只有500M,这个时候虚拟机不会直接告诉老年代你的空间不够,这个时候会进行第二次检查,检查自己的一个参数handlepromotionfailure的值是不是允许担保失败,如果允许担保失败,就进行第三次检查。

检查老年代最大可用连续空间是不是大于历次晋升到老年代平均对象大小,假设历次晋升到老年代平均对象大小是300M,现在老年代最大可用连续空间只有500M,很明显是大于的,那么它会进行一次有风险的minorgc,如果gc之后还是大于500M,那么就会引发fgc了,但是根据以往的一些经验,问题不大,这个就是允许担保失败。

假设历次晋升到老年代平均对象大小是700M,现在老年代最大可用连续空间只有500M,很明显是小于的,minorgc风险太大,这个时候就直接进行fgc了,这就是我们所说的空间分配担保。

Serial Old

Serial Old就是老年代下使用标记整理算法,单线程运行的垃圾回收器。

Parallel old

Parallel old也是老年代下使用标记整理算法,可以进行吞吐量控制的多线程回收器,在JDK1.6才开始提供,在JDK1.6之前,新生代使用ParallelScavenge 收集器只能搭配年老代的Serial Old收集器,只能保证新生代的吞吐量优先,无法保证整体的吞吐量,Parallel Old 正是为了在年老代同样提供吞吐量优先的垃圾收集器而出现的。

上面的Serial Old,Parallel Old这二个垃圾回收器使用的是标记整理算法.

标记整理算法

标记整理算法是标记后将存活对象移向内存的一端,然后清除端边界外的对象。标记整理算法可以弥补标记清除算法当中,内存碎片的缺点,也消除了复制算法当中,内存使用率只有90%的现象,不过也有缺点,就是效率也不高,它不仅要标记所有存活对象,还要整理所有存活对象的引用地址。从效率上来说,标记整理算法要低于复制算法。

CMS

CMS是老年代使用标记清除算法,并发收集低停顿的多线程垃圾回收器。这个垃圾回收器可以重点讲一下,CMS 工作机制相比其他的垃圾收集器来说更复杂,整个过程分为以下4个阶段:

初始标记,只是标记一下GC Roots,能直接关联的对象,速度很快,需要暂停所有的工作线程。

并发标记,进行GC Roots跟踪的过程,和用户线程一起工作,不需要暂停工作线程。

重新标记,为了修正在并发标记期间,因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,需要暂停所有的工作线程。

并发清除,清除 GC Roots 不可达对象,和用户线程一起工作,不需要暂停工作线程。由于耗时最长的并发标记和并发清除过程中,垃圾收集线程可以和用户现在一起并发工作,所以总体上来看CMS 收集器的内存回收和用户线程是一起并发地执行。

但是很明显无法处理浮动垃圾,就是已经标记过的对象,开始进行并发清除的时候,这个时候又有垃圾对象产生,这个时候,没办法清除这部分的浮动垃圾了,还有一个问题就是容易产生大量内存碎片,这和它的算法特性相关。

标记清除算法

标记清除算法分为两个阶段,标注和清除。标记阶段标记出所有需要回收的对象,清除阶段回收被标记的对象所占用的空间。

CMS使用标记清除算法看中的就是它的效率高,只不过内存碎片化严重,后续可能发生大对象不能找到可利用空间的问题。

G1

G1 收集器避免全区域垃圾收集,它把堆内存划分为大小固定的几个独立区域,每个区域又可以根据分代理论分为eden区,Survivor区,只要这个区域里面出现了一个对象,超过了这个区域空间的一半就可以把它当作大对象,g1专门开辟了一块空间用来存储大对象,这个区域的大小,可以通过jvm的参数去设置,取值范围是1~32mb之间,那么如果有一个对象超过了32mb,那么jvm会分配二个连续的区域,用来存储这个大对象。

跟踪这些区域的垃圾收集进度,同时在后台维护一个优先级列表,每次根据所允许的收集时间,优先回收垃圾最多的区域。区域划分和优先级区域回收机制,保证了G1 收集器可以在有限时间获得最高的垃圾收集效率。而且基于标记整理算法,不产生内存碎片。可以非常精确控制停顿时间,在不牺牲吞吐量前提下,实现低停顿垃圾回收。在jdk1.9的时候,被设置成默认的垃圾回收器了。

ZGC

JDK 11 中推出的一款低延迟垃圾回收器,停顿时间不超过 10ms,停顿时间不会因堆变大而变长,支持 8MB~4TB 级别的堆。

内存结构

ZGC 是页为单位进行划分。

  • 小型 Region(Small Region):容量固定为 2MB,用于放置小于 256KB 的小对象。
  • 中型 Region(Medium Region):容量固定为 32MB,用于放置大于 256KB 但是小于 4MB 的对象。
  • 大型 Region(Large Region):容量不固定,可以动态变化,但必须为 2MB 的整数倍,用于放置 4MB 或以上的大对象。
回收过程

ZGC 的三个 STW 阶段指的是:

  1. 初始标记(Initial Marking):ZGC 首先会扫描整个堆,标记出所有存活的对象。这个过程需要 STW,因为在这个阶段中,GC 算法需要扫描 Root 集合,也就是全局可达对象集合,并标记为活动对象,同时标记所有的引用对象以避免被回收。

  2. 再标记(Concurrent Marking):在标记所有存活对象之后,ZGC 会在堆中标记新对象的一些变化,并使用多线程进行 GC。这个阶段是 STW,因为 GC 算法需要遍历存活对象的引用字段,并标记丢弃的对象。这个阶段通常持续几毫秒而已。

  3. 初始转移(Initial Relocate):这个阶段是 ZGC 的独特之处。在所有存活对象都被标记后,ZGC 会开始对堆中的对象进行移动和重构。这个过程是并发的,不需要 STW。在这个阶段中,ZGC 会将活动对象移动到一个新的区域中,并释放已删除的对象的内存。这将导致堆对象的布局和连续性发生变化。

总之,ZGC 的三个 STW 阶段是为了进行有效的垃圾回收和内存整理,其中只有初始标记和再标记是 STW,而初始转移是并发执行的。这些阶段共同协作,使得 ZGC 能够在几毫秒内进行高效的垃圾回收操作,不会过多地影响应用程序的性能。

STW指的是Stop-The-World,即停顿整个程序世界的运行。在Java虚拟机中,STW一般用于垃圾回收、内存分配器的运行等等需要整个虚拟机运行环境停止的场景中。在STW期间,所有的线程都会暂停执行,直到垃圾回收等操作完成。由于STW会导致系统停顿,因此一般需要尽量缩短STW的时间以提升应用程序性能。

技术特性

ZGC中的读屏障是一种机制,用于保证对象读取的正确性。当线程读取一个对象时,读屏障会检查该对象是否正在被垃圾回收器回收,如果是,则会阻塞该线程,直到垃圾回收完成。这个机制保证了线程读取的对象都是有效的,避免了出现空指针等错误。

着色指针是ZGC中另一个重要的机制,用于标记对象是否存活。在ZGC中,所有对象都被标记为白色。当垃圾回收器开始执行时,它会从根对象开始遍历所有可达对象,并将它们标记为黑色。被标记为黑色的对象表示它们是存活的,而未被标记的白色对象则表示它们需要被回收。为了提高垃圾回收的效率,ZGC还使用了灰色和原色两个状态。灰色表示该对象已经被标记过,但其引用的其他对象还没有被标记。原色则表示该对象正在被扫描。

JVM调优

JVM调优步骤:首先,我们需要收集数据。我们可以使用jstat命令来监视JVM的内存和处理器使用信息,也可以使用jmap命令生成堆转储快照。另外,我们还可以使用GUI工具如JConsole或VisualVM对CPU、内存或堆使用状态进行监视。第二步,我们需要分析数据。通过使用工具分析收集到的数据,我们可以计算GC吞吐量和新生代大小等,也可以查看堆转储信息,分析堆中对象的分布情况,是否有内存泄漏等问题。接下来,第三步,我们需要制定具体的优化方案。我们可以根据分析的数据确定具体的优化方案,比如适当调整内存大小、调整垃圾回收机制、优化代码等。对于GC调优,可以尝试调整GC算法、分配大对象空间、增加GC并行度等。对于内存调优,可以尝试减少对象的创建、复用对象等。第四步,我们需要验证优化效果。我们可以使用性能测试工具如jmeter或ab进行压力测试,以验证优化效果是否符合预期。最后,第五步,我们需要持续监控。在优化后,我们需要持续监控应用程序,及时发现并解决新问题,进行JVM调优。

JVM调优情况十分复杂,各种情况都可能导致垃圾回收不能够达到预想的效果。对于场景问题,可以从如下几个大方向进行设计:

增大Eden 空间大小

大访问压力下,MGC 频繁一些是正常的,只要MGC 延迟不导致停顿时间过长或者引发FGC ,那可以适当的增大Eden 空间大小,降低频繁程度,同时要保证,空间增大对垃圾回收产生的停顿时间增长是可以接受的。

如果MinorGC 频繁,且容易引发 Full GC

需要从如下几个角度进行分析。

S1 区大小 < MGC 存活的对象大小,对象的年龄才1岁

每次MGC存活的对象的大小,是否能够全部移动到 S1区,如果S1 区大小 < MGC 存活的对象大小,这批对象会直接进入老年代。注意 了,这批对象的年龄才1岁,很有可能再多等1次MGC 就能被回收了,可是却进入了老年代,只能等到Full GC 进行回收,很可怕。这种情况下,应该在系统压测的情况下,实时监控MGC存活的对象大小,并合理调整eden和s 区的大小以及比例。

相同年龄的对象所占总空间大小>s1区空间大小的一半

还有一种情况会导致对象在未达到15岁之前,直接进入老年代,就是S1区的对象,相同年龄的对象所占总空间大小>s1区空间大小的一半,所以为了应对这种情况,对于S区的大小的调整就要考虑:尽量保证峰值状态下,S1区的对象所占空间能够在MGC的过程中,相同对象年龄所占空间不大于S1区空间的一半, 因此对于S1空间大小的调整,也是十分重要的。

解决方案
调整年龄阈值

可以通过设置 JVM 参数"-XX:MaxTenuringThreshold"来调整年龄阈值。该参数指定对象晋升老年代的最大年龄,通常默认值为15岁。可以逐渐增加该值,以减少老年代中相同年龄对象的数量。

请注意,调整年龄阈值需要根据具体应用程序的情况来确定。如果将年龄阈值设置得太高,可能会导致年轻代中的对象数量过多,从而增加Young GC的频率,进而影响系统性能。

增加S区的大小

如果S区足够大,那么S1区所占的比例就会更小。这样可以降低相同年龄段对象的总空间大小,从而使其不大于S1区的一半。

改变对象分配的位置
  • 设置对象的大小阈值。通过调整对象分配的大小阈值,可以让 JVM 将较大的对象分配到老年代中,减少新生代中对象数量,从而减少垃圾回收的频率。可以通过 -XX:PretenureSizeThreshold 参数来设置对象的大小阈值。
  • 调节新生代大小。通过调整新生代的大小,可以增加对象在新生代中的寿命,从而让更多的对象进入老年代,减少在新生代中对象的数量。可以通过调整 -Xmn 参数来设置新生代的大小。
  • 调节垃圾回收器参数。不同的垃圾回收器有不同的参数,可以根据具体的情况调节垃圾回收器的参数,以达到更好的效果。比如使用 G1 垃圾回收器,可以通过调节 -XX:G1HeapRegionSize 参数来控制 region 的大小,从而控制对象在不同 region 中的分配情况。

大对象创建频繁

由于大对象创建频繁,导致Full GC 频繁。对于大对象,JVM专门有参数进行控制,-XX: PretenureSizeThreshold。超过这个参数值的对象,会直接进入老年代,只能等到full GC 进行回收,所以在系统压测过程中,要重点监测大对象的产生。如果能够优化对象大小,则进行代码层面的优化,优化如:根据业务需求看是否可以将该大对象设置为单例模式下的对象,或者该大对象是否可以进行拆分使用,或者如果大对象确定使用完成后,将该对象赋值为null,方便垃圾回收。

代码层面无法优化

如果代码层面无法优化,则需要考虑:

调高-XX: PretenureSizeThreshold参数的大小

调高-XX: PretenureSizeThreshold参数的大小,使对象有机会在eden区创建,有机会经历MGC以被回收。但是这个参数的调整要结合MGC过程中Eden区的大小是否能够承载,包括S1区的大小承载问题。

大对象必须进入老年代

这是最不希望发生的情况, 如果必须要进入老年代,也要尽量保证,该对象确实是长时间使用的对象,放入老年代的总对象创建量不会造成老年代的内存空间迅速长满发生Full GC,在这种情况下,可以通过定时脚本,在业务系统不繁忙情况下,主动触发full gc。

MGC 与 FGC 停顿时间长

MGC 与 FGC 停顿时间长导致影响用户体验。其实对于停顿时间长的问题无非就两种情况:

gc 真实回收过程时间长

gc 真实回收过程时间长,即real time时间长。这种时间长大部分是因为内存过大导致,从标记到清理的过程中需要对很大的空间进行操作,导致停顿时间长。这种情况,要考虑减少堆内存大 小,包括新生代和老年代,比如之前使用16G的堆内存,可以考虑将16G 内存拆分为4个4G的内存区域,可以单台机器部署JVM逻辑集群,也可以为了降低GC回收时间,进行4节点的分布式部署,这里的分布式部署是为了降低 GC垃圾回收时间。

gc真实回收时间 real time 并不长

gc真实回收时间 real time 并不长,但是user time(用户态执行时间) 和 sys time(核心态执行时间)时间长,导致从客户角度来看,停顿时间过长。这种情况,要考虑线程是否及时达到了安全点,通过-XX:+PrintSafepointStatistics和-XX: PrintSafepointStatisticsCount=1去查看安全点日志,如果有长时间未达到安全点的线程,再通过参数-XX: +SafepointTimeout和-XX:SafepointTimeoutDelay=2000两个参数来找到大于2000ms到达安全点的线程,这里 的2000ms可以根据情况自己设置,然后对代码进行针对的调整。除了安全点问题,也有可能是操作系统本身负载比较高,导致处理速度过慢,线程达到安全点时间长,因此需要同时检测操作系统自身的运行情况。

内存泄漏导致的MGC和FGC频繁,最终引发oom

纯代码级别导致的MGC和FGC频繁。如果是这种情况,那就只能对代码进行大范围的调整,这种情况就非常多了,而且会很糟糕。如大循环体中的new 对象,未使用合理容器进行对象托管导致对象创建频繁,不合理的数据结构使用等等。 总之,JVM的调优无非就一个目的,在系统可接受的情况下达到一个合理的MGC和FGC的频率以及可接受的回收时间。

GC的回收对象的过程

当对象不可达就意味着这个对象要被回收,但是它不会立马就回收,对象不可达会把它放到一个F-Queue的队列里面,这个队列里面会启用一个低优先级的线程,去读取这些不可达的对象,然后一个一个的调用对象的finalize方法,如果对象的finalize方法被覆盖过,被调用过,这个时候虚拟机将这两种情况都视为"没有必要执行"。

这个时候这个不可达对象逃过了垃圾回收,稍后会由一条由虚拟机自动建立的、低调度优先级的 Finalizer线程去执行F-Queue中对象的finalize()方法。

finalize()方法是对象逃脱死亡命运的最后一次机会,收集器将对F-Queue中的对象进行第二次小规模的标记。

如果对象重新与引用链上的任何一个对象建立关联,那在第二次标记时它将被移出"即将回收"的集合。如果对象这时候还没有逃脱,那基本上它就真的要被回收了。

可达性分析

在可达性分析法中,可以作为GCRoots节点的,一般内容都会很大(方法区有时候就有数百M),要想检查完所有符合要求的对象,必定很费时间。另外可达性分析应当是对某瞬间的程序快照进行的,不然一边进行可达性分析,一边程序运行,最后出的结果肯定是牛头不对马尾。这个时间点导致GC进行时必须停顿所有Java执行的线程。

通过gc root根节点,从跟节点开始进行引用链的搜索,如果对象搜索不到,就证明这个对象是不可达的,就会在三色标记算法把这个对象标记为白色不可达,最终引发垃圾回收。

gc root是可达性分析的起点,gc root有几种,第一种,虚拟机栈里面引用的对象,也就是栈帧中的本地变量,第二种,本地方法栈里面的引用对象,第三种,方法区里面的静态属性引用的对象,第四种,方法区里面的常量引用对象,第五种,java虚拟机内部也有引用,这个也需要作为gc root,第六种,锁,锁的获取和释放,获取的话会持有对象,这些都是作为gc root的引用点。

四种引用类型

强引用

强引用就是最常见的Object a = new Object();这种就是最强的一个引用,只要这个关系还在,就不会被垃圾回收掉。

软引用

软引用就是描述这个对象还有用,但是它不是一个必须回收的对象,只有系统即将要发送内存溢出的情况下,会把这些对象列入回收的范围里面,进行第二次垃圾回收,如果回收之后,还是没有足够的内存,才会抛出异常。

弱引用

弱引用,被弱引用引用的对象,只能生存到下一次垃圾回收器进行垃圾收集。

虚引用

虚引用,它是最弱的一种引用,可以称为幽灵引用,它的存在不会对结构造成任何的影响,没法通过虚引用找到这个对象的实例。

gc的过程中对象是否能回收

当对象不可达就意味着这个对象要被回收,但是它不会立马就回收,对象不可达会把它放到一个F-Queue的队列里面,这个队列里面会启用一个低优先级的线程,去读取这些不可达的对象,然后一个一个的调用对象的finalize方法,如果对象的finalize方法被覆盖过,被调用过,这个时候虚拟机将这两种情况都视为"没有必要执行"。

这个时候这个不可达对象逃过了垃圾回收,稍后会由一条由虚拟机自动建立的、低调度优先级的 Finalizer线程去执行F-Queue中对象的finalize()方法。

finalize()方法是对象逃脱死亡命运的最后一次机会,收集器将对F-Queue中的对象进行第二次小规模的标记。

如果对象重新与引用链上的任何一个对象建立关联,那在第二次标记时它将被移出"即将回收"的集合。

如果对象这时候还没有逃脱,那基本上它就真的要被回收了。

三色标记

三色标记,这三色就是白黑灰,白色表示对象不可达,黑色表示已经被访问过了,它关联的对象也扫描了,灰色就是还有一部分对象没有被扫描过。

跨代引用

跨代引用,年轻代中有一个对象被老年代的对象引用了,这个时候进行minor gc。正常我们的思路是,年轻代里面的对象被老年代里面的对象引用的话,就进行一个遍历,遍历老年代里面的对象。但是老年代里面的对象是很多的,遍历这个是很消耗性能的,这个时候jvm引入了一个记忆集的抽象数据结构。它用于记录从非收集区域指向收集区域的一个指针集合的抽象数据结构。比如说,我们在年轻代里面进行minor gc,它里面有一个记忆集,记录了老年代引用年轻代的对象的指针。如果记忆集里面有当前对象的引用,那么这个对象就不能被回收。

内存泄漏

内存泄漏:是指创建的对象已经没有用处,正常情况下应该会被垃圾收集器回收,但是由于该对象仍然 被其他对象进行了无效引用,导致不能够被垃圾收集器及时清理,这种现象称之为内存泄漏。

内存堆积

内存泄漏会导致内存堆积,最终发生内存溢出,导致OOM。 发生内存泄漏大部分是由于程序代码导致的,排查方法一般是使用 visualVM 进行heap dump,查看占用 空间比较多的 class 对象,然后检查该对象的instances 以及 reference引用,最终定位到程序代码。 如果堆内存比较大,进行head dump 产生的资源消耗不可接受,可以尝试使用轻量级的jmap生成堆转储快照 分析,思路与使用可视化工具一样。

分布式事务

InnoDB存储引擎提供了对XA事务的支持,并通过XA事务来支持分布式事务的实现。分布式事务指的是允许多个独立的事务资源(transactional resources)参与到一个全局的事务中。事务资源通常是关系型数据库系统,但也可以是其他类型的资源。全局事务要求在其中的所有参与的事务要么都提交,要么都回滚,这对于事务原有的ACID要求又有了提高。另外,在使用分布式事务时,InnoDB存储引擎的事务隔离级别必须设置为SERIALIZABLE。XA事务允许不同数据库之间的分布式事务,如一台服务器是MySQL数据库的,另一台是Oracle数据库的,又可能还有一台服务器是SQL Server数据库的,只要参与在全局事务中的每个节点都支持XA事务。分布式事务可能在银行系统的转账中比较常见,如用户David需要从上海转10 000元到北京的用户Mariah的银行卡中:

java 复制代码
#Bank@Shanghai:
UPDATE account SET money=money-10000WHEREuser='David';
#Bank@Beijing
UPDATE account SET money=money+10000WHEREuser='Mariah';

在这种情况下,一定需要使用分布式事务来保证数据的安全。如果发生的操作不能全部提交或回滚,那么任何一个结点出现问题都会导致严重的结果。要么是David的账户被扣款,但是Mariah没收到,又或者是David的账户没有扣款,Mariah却收到钱了。

XA事务由一个或多个资源管理器(Resource Managers)、一个事务管理器(TransactionManager)以 及一个应用程序(ApplicationProgram)组成。❑资源管理器:提供访问事务资源的方法。通常一个数据库就是一个资源管理器。❑事务管理器:协调参与全局事务中的各个事务。需要和参与全局事务的所有资源管理器进行通信。❑应用程序:定义事务的边界,指定全局事务中的操作。在MySQL数据库的分布式事务中,资源管理器就是MySQL数据库,事务管理器为连接MySQL服务器的客户端。

分布式事务使用两段式提交(two-phasecommit)的方式。在第一阶段,所有参与全局事务的节点都开始准备(PREPARE),告诉事务管理器它们准备好提交了。在第二阶段,事务管理器告诉资源管理器执行ROLLBACK还是COMMIT。如果任何一个节点显示不能提交,则所有的节点都被告知需要回滚。可见与本地事务不同的是,分布式事务需要多一次的PREPARE操作,待收到所有节点的同意信息后,再进行COMMIT或是ROLLBACK操作。

最为常见的内部XA事务存在于binlog与InnoDB存储引擎之间。由于复制的需要,因此目前绝大多数的数据库都开启了binlog功能。在事务提交时,先写二进制日志,再写InnoDB存储引擎的重做日志。对上述两个操作的要求,也是原子的,即二进制日志和重做日志必须同时写入。若二进制日志先写了,而在写入InnoDB存储引擎时发生了宕机,那么slave可能会接收到master传过去的二进制日志并执行,最终导致了主从不一致的情况。

CAP理论

分布式环境下(数据分布)要任何时刻保证数据一致性是不可能的,只能采取妥协的方案来保证数据最终一致性。这个也就是著名的CAP定理。

C一致性:对于指定的客户端来说,读操作保证能够返回最新的写操作结果。

A可用性:非故障的节点在合理的时间内返回合理的响应(不是错误和超时的响应)。

P分区容错性:当出现网络分区后,系统能够继续"履行职责"。

对于一个分布式系统而言,网络失效一定会发生,分区容错性P其实就是每个服务都会有多个节点(一般都是主从),这样就可以保证此服务的一个节点挂了之后,此服务的其他节点依然可以响应,其实这就是分区容错性。

但是一个服务有多个节点之后,一个服务的多个节点之间的数据为了保持一致性就要进行的数据复制,在此过程中就会出现数据一致性C(强一致性)的问题。也就是说,分区耐受性是必须要保证的,那么在可用性和一致性就必须二选一。网络不可用的时候,如果选择了一致性,系统就可能返回一个错误码或者干脆超时,即系统不可用。如果选择了可用性,那么系统总是可以返回一个数据,但是并不能保证这个数据是最新的。

BASE理论

BASE 理论是对 CAP 理论的延伸,核心思想是即使无法做到强一致性,但应用可以采用适合的方式达到最终一致性。

基本可用: 基本可用是指分布式系统在出现故障的时候,允许损失部分可用性,即保证核心可用。电商大促时,为了应对访问量激增,部分用户可能会被引导到降级页面,服务层也可能只提供降级服务。这就是损失部分可用性的体现。

软状态: 软状态是指允许系统存在中间状态,而该中间状态不会影响系统整体可用性。分布式存储中一般一份数据至少会有三个副本,允许不同节点间副本同步的延时就是软状态的体现。MySQL Replication 的异步复制也是一种体现。

最终一致性: 最终一致性是指系统中的所有数据副本经过一定时间后,最终能够达到一致的状态。弱一致性和强一致性相反,最终一致性是弱一致性的一种特殊情况。

应用场景:

Erueka:erueka是SpringCloud系列用来做服务注册和发现的组件,作为服务发现的一个实现,在设计的时候就更考虑了可用性,保证了AP。

Zookeeper:Zookeeper在实现上牺牲了可用性,保证了一致性(单调一致性)和分区容错性,也即:CP。所以这也是SpringCloud抛弃了zookeeper而选择Erueka的原因。

具体根据各自业务场景所需来制定相应的策略而选择适合的产品服务等。例如:支付订单场景中,由于分布式本身就在数据一致性上面很难保证,从A服务到B服务的订单数据有可能由于服务宕机或其他原因而造成数据不一致性。因此此类场景会酌情考虑:AP,不强制保证数据一致性,但保证数据最终一致性。

分布式事务指事务的操作位于不同的节点上,需要保证事务的 AICD 特性,在下单场景下,库存和订单如果不在同一个节点上,就涉及分布式事务。

两阶段提交(2PC)

第一阶段:协调者询问参与者事务是否执行成功,参与者发回事务执行结果。这一阶段的协调者有超时机制,假设因为网络原因没有收到某参与者的响应或某参与者挂了,那么超时后就会判断事务失败,向所有参与者发送回滚命令。

第二阶段:如果事务在每个参与者上都执行成功,事务协调者才发送通知让参与者提交事务;否则,协调者发送通知让参与者回滚事务。这一阶段的协调者的没法超时,只能不断重试。

协调者是一个单点,存在单点故障问题。

假设协调者在发送准备命令之前挂了,还行等于事务还没开始。

假设协调者在发送准备命令之后挂了,这就不太行了,有些参与者等于都执行了处于事务资源锁定的状态。不仅事务执行不下去,还会因为锁定了一些公共资源而阻塞系统其它操作。

假设协调者在发送回滚事务命令之前挂了,那么事务也是执行不下去,且在第一阶段那些准备成功参与者都阻塞着。

假设协调者在发送回滚事务命令之后挂了,这个还行,至少命令发出去了,很大的概率都会回滚成功,资源都会释放。但是如果出现网络分区问题,某些参与者将因为收不到命令而阻塞着。

假设协调者在发送提交事务命令之前挂了,这个不行,傻了!这下是所有资源都阻塞着。

假设协调者在发送提交事务命令之后挂了,这个还行,也是至少命令发出去了,很大概率都会提交成功,然后释放资源,但是如果出现网络分区问题某些参与者将因为收不到命令而阻塞着。

存在的缺点:

同步阻塞 所有事务参与者在等待其它参与者响应的时候都处于同步阻塞状态,无法进行其它操作。

单点问题 协调者在 2PC 中起到非常大的作用,发生故障将会造成很大影响。特别是在阶段二发生故障,所有参与者会一直等待状态,无法完成其它操作。

数据不一致 在阶段二,如果协调者只发送了部分 Commit 消息,此时网络发生异常,那么只有部分参与者接收到 Commit 消息,也就是说只有部分参与者提交了事务,使得系统数据不一致。

太过保守 任意一个节点失败就会导致整个事务失败,没有完善的容错机制。

三阶段提交(3PC)

相比于 2PC 它在参与者中也引入了超时机制,并且新增了一个阶段使得参与者可以利用这一个阶段统一各自的状态,3PC 包含了三个阶段,分别是准备阶段、预提交阶段和提交阶段

准备阶段的变更成不会直接执行事务,而是会先去询问此时的参与者是否有条件接这个事务,因此不会一来就干活直接锁资源,使得在某些资源不可用的情况下所有参与者都阻塞着。

而预提交阶段的引入起到了一个统一状态的作用,它像一道栅栏,表明在预提交阶段前所有参与者其实还未都回应,在预处理阶段表明所有参与者都已经回应了。

假如你是一位参与者,你知道自己进入了预提交状态那你就可以推断出来其他参与者也都进入了预提交状态。

缺点:

多引入一个阶段也多一个交互,因此性能会差一些,而且绝大部分的情况下资源应该都是可用的,这样等于每次明知可用执行还得询问一次。

和2PC对比:

2PC 是同步阻塞的,协调者挂在了提交请求还未发出去的时候是最伤的,所有参与者都已经锁定资源并且阻塞等待着,提交阶段 2PC 协调者和某参与者都挂了之后新选举的协调者不知道当前应该提交还是回滚

改进的优势:

新协调者来的时候发现有一个参与者处于预提交或者提交阶段,那么表明已经经过了所有参与者的确认了,所以此时执行的就是提交命令。

所以说 3PC 就是通过引入预提交阶段来使得参与者之间的状态得到统一,也就是留了一个阶段让大家同步一下。

但是这也只能让协调者知道该如果做,但不能保证这样做一定对,这其实和上面 2PC 分析一致,因为挂了的参与者到底有没有执行事务无法断定。

所以说 3PC 通过预提交阶段可以减少故障恢复时候的复杂性,但是不能保证数据一致,除非挂了的那个参与者恢复。

总结一下

3PC 相对于 2PC 做了一定的改进:引入了参与者超时机制,并且增加了预提交阶段使得故障恢复之后协调者的决策复杂度降低,但整体的交互过程更长了,性能有所下降,并且还是会存在数据不一致问题。

2PC 和 3PC 都不能保证数据100%一致,因此一般都需要有定时扫描补偿机制

补偿事务(TCC)

针对每个操作,都要注册一个与其对应的确认和补偿(撤销)操作。它分为三个阶段:

Try 阶段主要是对业务系统做检测及资源预留

Confirm 阶段主要是对业务系统做确认提交,Try阶段执行成功并开始执行 Confirm阶段时,默认 Confirm阶段是不会出错的。即:只要Try成功,Confirm一定成功。

Cancel 阶段主要是在业务执行错误,需要回滚的状态下执行的业务取消,预留资源释放。

举个例子,假入 Bob 要向 Smith 转账,思路大概是:我们有一个本地方法,里面依次调用

首先在 Try 阶段,要先调用远程接口把 Smith 和 Bob 的钱给冻结起来。

在 Confirm 阶段,执行远程调用的转账的操作,转账成功进行解冻。

如果第2步执行成功,那么转账成功,如果第二步执行失败,则调用远程冻结接口对应的解冻方法 (Cancel)。

优点: 跟2PC比起来,实现以及流程相对简单了一些,但数据的一致性比2PC也要差一些

缺点: 缺点还是比较明显的,在2,3步中都有可能失败。TCC属于应用层的一种补偿方式,所以需要程序员在实现的时候多写很多补偿的代码,在一些场景中,一些业务流程可能用TCC不太好定义及处理

MQ 事务消息

有一些第三方的MQ是支持事务消息的,比如RocketMQ,他们支持事务消息的方式也是类似于采用的二阶段提交,但是市面上一些主流的MQ都是不支持事务消息的,比如 RabbitMQ 和 Kafka 都不支持。

第一阶段Prepared消息,会拿到消息的地址。第二阶段执行本地事务,第三阶段通过第一阶段拿到的地址去访问消息,并修改状态。也就是说在业务方法内要想消息队列提交两次请求,一次发送消息和一次确认消息。如果确认消息发送失败了RocketMQ会定期扫描消息集群中的事务消息,这时候发现了Prepared消息,它会向消息发送者确认,所以生产方需要实现一个check接口,RocketMQ会根据发送端设置的策略来决定是回滚还是继续发送确认消息。这样就保证了消息发送与本地事务同时成功或同时失败。

优点: 实现了最终一致性,不需要依赖本地数据库事务。

缺点: 实现难度大,主流MQ不支持,RocketMQ事务消息部分代码也未开源。

最大努力通知

其实我觉得本地消息表也可以算最大努力,事务消息也可以算最大努力。

就本地消息表来说会有后台任务定时去查看未完成的消息,然后去调用对应的服务,当一个消息多次调用都失败的时候可以记录下然后引入人工,或者直接舍弃。这其实算是最大努力了。

事务消息也是一样,当半消息被commit了之后确实就是普通消息了,如果订阅者一直不消费或者消费不了则会一直重试,到最后进入死信队列。其实这也算最大努力。

所以最大努力通知其实只是表明了一种柔性事务的思想:我已经尽力我最大的努力想达成事务的最终一致了。适用于对时间不敏感的业务,例如短信通知。

各个场景对比的解决方案:

2PC 和 3PC 是一种强一致性事务,不过还是有数据不一致,阻塞等风险,而且只能用在数据库层面。

而 TCC 是一种补偿性事务思想,适用的范围更广,在业务层面实现,因此对业务的侵入性较大,每一个操作都需要实现对应的三个方法。

本地消息、事务消息和最大努力通知其实都是最终一致性事务,因此适用于一些对时间不敏感的业务。

MySQL数据库

深入理解MySQL的索引数据结构、脏读、 不可重复读、幻读、隔离级别、原子性底层实现原理(undo log日志 )、 一致性底层实现原理、持久性底层实现原理(redo log机制)、隔离性底层实现原理(MVCC多版本并发控制)、BufferPool缓存机制、行锁、表锁、间隙锁、死锁、主键自增长实现原理、索引失效、聚集索引、辅助索引、覆盖索引、联合索引、SQL的执行流程、有MySQL调优经验,如表结构设计优化、SQL优化、灾备处理、异常发现处理、数据服务、数据分区分库分表、主从复制、读写分离、高可用(双主故障切换、高可用性与可伸缩性、组复制)经验。

索引数据结构

磁盘存储

mysql是从磁盘读取数据到内存的,是以磁盘块为基本单位的,位于同一磁盘块中的数据会被一次性读取出来,不是按需读取。以InnoDB存储引擎来说,它使用页作为数据读取单位,页是其磁盘管理的最小单位,默认大小是16kb。系统的一个磁盘块的存储空间往往没有这么大,所以InnoDB每次申请磁盘空间时都会是多个地址连续磁盘块来达到页的大小16KB。假设一行数据的大小是 1k,那么一个页可以存放 16 行这样的数据。那如果想查找某个页里面的一个数据的话,得首先找到他所在的页。

在查询数据时一个页中的每条数据都能定位数据记录的位置,这会减少磁盘 I/O 的次数,提高查询效率。InnoDB存储引擎在设计时是将根节点常驻内存的,力求达到树的深度不超过 3,也就是说I/O不超过3次。

树形结构的数据可以让系统高效的找到数据所在的磁盘块,这里就可以说一下这个b树和b+树了

B树的结构是每个节点中有key也有value,而每一个页的存储空间是16kb,如果数据较大时将会导致一页能存储数据量的数量很小。

B+Tree的结构是将所有数据记录节点按照键值大小顺序存放在同一层的叶子节点上,而非叶子节点上只存储 key 值信息,这样可以大大加大每个节点存储的key 值数量,降低B+Tree的高度。B+树是为磁盘或其他直接存取辅助设备设计的一种平衡查找树。在B+树中,所有记录节点都是按键值的大小顺序存放在同一层的叶子节点上,由各叶子节点指针进行连接。 B+索引在数据库中有一个特点是高扇出性,因此在数据库中,B+树的高度一般都在2~4层,这也就是说查找某一键值的行记录时最多只需要2到4次IO,这倒不错。因为当前一般的机械磁盘每秒至少可以做100次IO, 2~4次的IO意味着查询时间只需0.02~0.04秒。 数据库中的B+树索引可以分为聚集索引和辅助索引。

假设每条sql信息为1kb,主键ID为bigint型,一颗高度为2,3,4高度的B+树分别可以存储多少行数据?

因为单个页的大小为 16kb,而一行数据的大小为 1kb,也就是说一页可以存 放 16 行数据。然后因为非叶子节点的结构是:"页指针 + 键值",我们假设主键ID 为 bigint 类型,长度为 8 字节(byte),而指针大小在 InnoDB 源码中设置为 6 字节(byte),这样一共 14 字节(byte),因为一个页可以存放 16k 个 byte,所以一个页可以存放的指针个数为 16384/14=1170 个。因此一个两层的 B + 树可 以存放的数据行的个数为:1170*16=18720(行)。

也就是说第一层的页,即根页可以存放 1170 个指针,然后第二层的每个页也可以 存放 1170 个指针。这样一共可以存放 1170*1170 个指针,所以一共可以存放 1170*1170*16=21902400(2千万 左右) 行记录。也就是说一个三层的 B + 树就可以存放千万级别的数据了。

高度为4的B+树则是 1170*1170*1170*16 约等于 2000万*1000,1000个 2000 万就是 200亿行的数据了。

为什么选用B+树做索引而不选用二叉树或者B树?

b 树和 b + 树应用在数据库索引,可以认为是 m 叉的多路平衡查找树,但是从理论上讲,二叉树查找速度和比较次数都是最小的,为什么不用二叉树呢? 因为我们要考虑磁盘 IO 的影响,它相对于内存来说是很慢的。数据库索引是存储在磁盘上的,当数据量大时, 就不能把整个索引全部加载到内存了,只能逐一加载每一个磁盘页(对应索引树的节点)。所以我们要减少 IO 次数,对于树来说,IO 次数就是树的高度,而 "矮胖" 就是 b 树的特征之一,它的每个节点最多包含 m 个孩子, m 称为 b 树的阶。 为什么不用B树呢? b + 树,是 b 树的一种变体,查询性能更好。 b + 树相比于 b 树的查询优势:

1.b + 树的中间节点不保存数据,所以磁盘页能容纳更多节点元素,更 "矮胖"。B 树不管叶子节点还是非叶子节 点,都会保存数据,这样导致在非叶子节点中能保存的指针数量变少(有些资料也称为扇出),指针少的情况 下要保存大量数据,只能增加树的高度,导致 IO 操作变多,查询性能变低;

2.b + 树查询必须查找到叶子节点,b 树只要匹配到即可直接返回。因此 b + 树查找更稳定(并不慢),必须查 找到叶子节点;而B树,如果数据在根节点,最快,在叶子节点最慢,查询效率不稳定。

3.对于范围查找来说,b + 树只需遍历叶子节点链表即可,并且不需要排序操作,因为叶子节点已经对索引进行 了排序操作。b 树却需要重复地中序遍历,找到所有的范围内的节点。

为什么用 B+ 树做索引而不用哈希表做索引?

1、模糊查找不支持:哈希表是把索引字段映射成对应的哈希码然后再存放在对应的位置, 这样的话,如果我们要进行模糊查找的话,显然哈希表这种结构是不支持的,只能遍历这个 表。而 B + 树则可以通过最左前缀原则快速找到对应的数据。

2、范围查找不支持:如果我们要进行范围查找,例如查找 ID 为 100 ~ 400 的人,哈希表同 样不支持,只能遍历全表。

3、哈希冲突问题:索引字段通过哈希映射成哈希码,如果很多字段都刚好映射到相同值的 哈希码的话,那么形成的索引结构将会是一条很长的链表,这样的话,查找的时间就会大大增加。

隔离级别

数据库隔离的四个级别分别为:

Read Uncommitted(读未提交)

在一个事务处理过程里读取了另一个未提交的事务中的数据。会导致脏读。

脏读(Drity Read):

某个事务已更新一份数据,另一个事务在此时读取了同一份数据,由于某些原因,前一个回滚了操作,则后一个事务所读取的数据就会是不正确的。

举个例子,公司发工资了,领导把四万块钱打到我的账号上,但是该事务并未提交,而我正好去查看账户,发现工资已经到账,是四万,非常高兴。可是不幸的是,领导发现发给我的工资金额不对,是三万五元,于是迅速修改金额,将事务提交,最后我实际的工资只有三万五元,我就白高兴一场。

Read Committed(读已提交)

这是大多数数据库系统的默认隔离级别,但不是MySQL默认的。会导致不可重复读,事务a读取数据,事务b立马修改了这个数据并且提交事务给数据库,事务a再次读取这个数据就得到了不同的结果。

不可重复读(Non-repeatable read):

在一个事务的两次查询之中数据不一致,这可能是两次查询过程中间更新了原有的数据。

举个例子,我拿着工资卡去消费,系统读取到卡里确实有一百块钱,这个时候我的女朋友刚好用我的工资卡在网上转账,把我工资卡的一百块钱转到另一账户,并在我之前提交了事务,当我扣款时,系统检查到我的工资卡已经没有钱,扣款失败,廖志伟十分纳闷,明明卡里有钱的。

Repeatable Read(可重读)

这是MySQL的默认事务隔离级别,它确保同一事务的多个实例在并发读取数据时,会看到同样的数据行。会导致幻读,InnoDB和Falcon存储引擎通过多版本并发控制机制解决了该问题。

幻读(Phantom Read):

在一个事务的两次查询中数据笔数不一致,例如有一个事务查询了几列(Row)数据,而另一个事务却在此时插入了新的几列数据,先前的事务在接下来的查询中,就会发现有几列数据是它先前所没有的。

举个例子,当我拿着工资卡去消费时,一旦系统开始读取工资卡信息,这个时候事务开始,我的女朋友就不可能对该记录进行修改,也就是我的女朋友不能在这个时候转账。这就避免了不可重复读。假设我的女朋友在银行部门工作,她时常通过银行内部系统查看我的工资卡消费记录。有一天,她正在查询到我当月信用卡的总消费金额(select sum(amount) from transaction where month = 本月)为80元,而我此时正好在外面胡吃海喝后在收银台买单,消费1000元,即新增了一条1000元的消费记录(insert transaction ... ),并提交了事务,随后我的女朋友把我当月工资卡消费的明细打印到A4纸上,却发现消费总额为1080元,我女朋友很诧异,以为出现了幻觉,幻读就这样产生了。

Serializable(可串行化)(更高级别隔离,避免脏读,避免不可重复读,避免幻读)

这是最高的隔离级别,它通过强制事务排序,使之不可能相互冲突,从而解决幻读问题。简言之,它是在每个读的数据行上加上共享锁。在这个级别,可能导致大量的超时现象和锁竞争。

ACID底层实现原理

redo log 和bin log有什么区别?binlog做什么用的

在MySQL数据库中有一种二进制日志(binlog),其用来进行POINT-IN-TIME(PIT)的恢复及主从复制(Replication)环境的建立。从表面上看其和重做日志非常相似,都是记录了对于数据库操作的日志。然而,从本质上来看,两者有着非常大的不同。首先,重做日志是在InnoDB存储引擎层产生,而二进制日志是在MySQL数据库的上层产生的,并且二进制日志不仅仅针对于InnoDB存储引擎,MySQL数据库中的任何存储引擎对于数据库的更改都会产生二进制日志。其次,两种日志记录的内容形式不同。MySQL数据库上层的二进制日志bin log是一种逻辑日志,其记录的是对应的SQL语句。而InnoDB存储引擎层面的重做日志是物理格式日志,其记录的是对于每个页的修改。

什么是undolog,有什么用?

重做日志记录了事务的行为,可以很好地通过其对页进行"重做"操作。但是事务有时还需要进行回滚操作,这时就需要undo。因此在对数据库进行修改时,InnoDB存储引擎不但会产生redo,还会产生一定量的undo。这样如果用户执行的事务或语句由于某种原因失败了,又或者用户用一条ROLLBACK语句请求回滚,就可以利用这些undo信息将数据回滚到修改之前的样子。redo存放在重做日志文件中,与redo不同,undo存放在数据库内部的一个特殊段(segment)中,这个段称为undo段(undo segment)。undo段位于共享表空间内。可以通过py_innodb_page_info.py工具来查看当前共享表空间中undo的数量除了回滚操作,undo的另一个作用是MVCC,即在InnoDB存储引擎中MVCC的实现是通过undo来完成。当用户读取一行记录时,若该记录已经被其他事务占用,当前事务可以通过undo读取之前的行版本信息,以此实现非锁定读取。最后也是最为重要的一点是,undo log会产生redo log,也就是undo log的产生会伴随着redolog的产生,这是因为undo log也需要持久性的保护。

原子性底层实现原理

A(原子性),要么全部完成,要么完全不起作用。底层实现是通过undo log日志去实现的,当这个事务对数据库进行修改的时候,innodb 生成对应undo log,undolog有多个版本,并且存放的是与上一个版本相反的操作,他会记录这个SQL执行的相关信息,如果SQL执行失败发生回滚,innodb 根据这个undo log内容去做相反的工作,比如说我执行了一个insert 操作,那么回滚的时候,就会执行一个相反的操作,就是delete,对应update,回滚的时候也是执行相反的update。这就是原子性的底层实现。

一致性实现原理

一旦事务完成,不管成功还是失败,数据处于一致的状态,而不会是部分完成,部分失败。事务执行前后,数据库的完整约束没有遭受破坏,事务执行前后都是合法的一个数据状态。事务的AID是数据库的特征,也就是依赖数据库的具体实现。而唯独这个C,实际上它依赖于应用层,也就是依赖于开发者。这里的一致性,是指数据从一种正确的状态,跳转到另一种正确的状态。

举例:账户A转1000到账户B,A转账的金额,必须小于等于自己的账户余额,即事务提交时,A的账户余额不能为负数,可以通过数据库约束,保证账户金额的字段值大于等于0。

持久性底层实现原理

一旦事务完成,无论发生什么系统错误,它的结果都不会受到影响,事务的结果被写到持久化存储器中。

底层实现原理是:redo log机制去实现的,mysql 的数据是存放在这个磁盘上的,但是每次去读数据都需要通过这个磁盘io,效率就很低,使用 innodb 提供了一个缓存 buffer,这个 buffer 中包含了磁盘部分数据页的一个映射,作为访问数据库的一个缓冲,从数据库读取一个数据,就会先从这个 buffer 中获取,如果 buffer 中没有,就从这个磁盘中获取,读取完再放到这个 buffer 缓冲中,当数据库写入数据的时候,也会首先向这个 buffer 中写入数据,定期将 buffer 中的数据刷新到磁盘中,进行持久化的一个操作。如果 buffer 中的数据还没来得及同步到这个磁盘上,这个时候 MySQL 宕机了,buffer 里面的数据就会丢失,造成数据丢失的情况,持久性就无法保证了。使用 redolog 解决这个问题,当数据库的数据要进行新增或者是修改的时候,除了修改这个 buffer 中的数据,还会把这次的操作写入到这个 redolog 中,如果 msyql 宕机了,就可以通过 redolog 去恢复数据,redolog 是预写式日志,会先将所有的修改写入到日志里面,然后再更新到 buffer 里面,保证了这个数据不会丢失,保证了数据的持久性,redolog 属于记录修改的操作,主要为了提交或者恢复数据使用!

事务隔离性由之前讲述的锁来实现。redo log称为重做日志,用来保证事务的持久性。redo通常是物理日志,记录的是页的物理修改操作。重做日志用来实现事务的持久性,即事务ACID中的D。其由两部分组成:一是内存中的重做日志缓冲,是易丢失的;二是重做日志文件,是持久的。

隔离性实现原理(一致性非锁定读(MVCC的原理))

多事务会同时处理相同的数据,因此每个事务都应该与其他事务隔离开来,防止数据损坏。

底层实现原理:

写-写操作:通过加锁,原理和 java 里面的锁机制是一样的。

写-读操作:MVCC多版本并发控制,对一行数据的读和写两个操作默认是不会通过加锁互斥来保证隔离性,避免了频繁加锁互斥。

一行数据被多个事务依次修改过后,在每个事务修改完后,Mysql会保留修改前的数据undo回滚日志,并且用两个隐藏字段trx_id和roll_pointer把这些undo日志串联起来形成一个历史记录版本链。

在可重复读隔离级别,事务开启的时候,执行任何查询sql会生成当前事务的一致性视图read-view,也就是第一次select生成一个版本,read-view视图在事务结束之前都不会变化。

如果是读已提交隔离级别,在每次执行查询sql时都会重新生成视图read-view,也就是每次select生成一个版本。

执行查询时,从对应版本链里的最新数据开始逐条跟read-view做比,会拿着当前事务的id和readview视图数组里面的已创建的最小事务id和已创建的最大事务id进行比较,这里面分为三种情况:

第一种,当前事务的id小于数组里面最小的id,说明这个版本是已提交的事务生成的,表示这个数据可见。

第二种,当前事务比已创建的最大事务id还要大,说明这个版本还没开启事务,表示不可见。

第三种,如果刚好在这个区间,被访问的事务id在最小事务id与最大事务id之间,又有二种情况:

第一种,这个版本是由还没提交的事务生成的,不可见,

第二种,表示这个版本是已经提交了的事务生成的,可见。

做比对,得到最终的快照结果,通过这种机制保证了隔离性。

对于删除的情况可以认为是update的特殊情况,会将版本链上最新的数据复制一份,然后将trx_id修改成删除操作的 trx_id,同时在该条记录的头信息(record header)里的(deleted_flag)标记位写上true,来表示当前记录已经被 删除,在查询时按照上面的规则查到对应的记录如果delete_flag标记位为true,意味着记录已被删除,则不返回数 据。

注意:begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个修改操作InnoDB表的语句, 事务才真正启动,才会向mysql申请事务id,mysql内部是严格按照事务的启动顺序来分配事务id的。

总结: MVCC机制的实现就是通过read-view机制与undo版本链比对机制,使得不同的事务会根据数据版本链对比规则读取同一条数据在版本链上的不同版本数据。

BufferPool缓存机制

mysql 的数据是存放在磁盘上的,但是每次去读数据都需要通过这个磁盘io,效率就很低,使用 innodb 提供了一个缓存 buffer,这个 buffer 中包含了磁盘部分数据页的一个映射,作为访问数据库的一个缓冲,从数据库读取一个数据,就会先从这个 buffer 中获取,如果 buffer 中没有,就从这个磁盘中获取,读取完再放到这个 buffer 缓冲中,当数据库写入数据的时候,也会首先向这个 buffer 中写入数据,定期将 buffer 中的数据刷新到磁盘中,进行持久化的一个操作。

为什么Mysql不能直接更新磁盘上的数据而且设置这么一套复杂的机制来执行SQL了?

因为来一个请求就直接对磁盘文件进行随机读写,然后更新磁盘文件里的数据性能可能相当差。

因为磁盘随机读写的性能是非常差的,所以直接更新磁盘文件是不能让数据库抗住很高并发的。 Mysql这套机制看起来复杂,但它可以保证每个更新请求都是更新内存BufferPool,然后顺序写日志文件,同时还能保证各种异常情况下的数据一致性。 更新内存的性能是极高的,然后顺序写磁盘上的日志文件的性能也是非常高的,要远高于随机读写磁盘文件。 正是通过这套机制,才能让我们的MySQL数据库在较高配置的机器上每秒可以抗下几干的读写请求。

行锁、表锁、间隙锁、死锁

行锁的算法

InnoDB存储引擎有3种行锁的算法,其分别是:

❑Record Lock:锁定单个行记录的锁,防止其他事务对此行进行update和delete。在读已提交、可重复读隔离级别下都支持。

❑Gap Lock:间隙锁,锁定索引记录间隙(不含该记录),确保索引记录间隙不变,防止其他事务在这个间隙进行insert,产生幻读。在可重复读隔离级别下支持。

❑Next-Key Lock∶锁和间隙锁组合,同时锁住数据,并锁住数据前面的间隙Gap。在可重复读隔离级别下支持。

行锁的表现

将mysql数据库改为手动提交

步骤1:

打开窗口1:更新数据,update test_innodb_lock set a = 1 where b = 2;然后查询select * from test_innodb_lock where b = 2;,发现a已经改为1了。

由于还没有提交事务,所以b=2这行数据还是被update持有锁,对于其他事务是不可见的,避免了脏读。

打开窗口2:查询select * from test_innodb_lock where b = 2;发现a的值还是没变。更新数据,update test_innodb_lock set a = 2 where b = 2;发现一直阻塞,没有继续往下执行。

由于第一个会话持有了这一行的锁,第二个窗口的会话就对这一行进行修改。

步骤2:

窗口1:提交事务

窗口2:查询select * from test_innodb_lock where b = 2;发现a的值改为1了。更新数据,update test_innodb_lock set a = 2 where b = 2;发现可以更新成功。

表锁的表现

将mysql数据库改为手动提交

步骤1:

窗口1:更新数据,update test_innodb_lock set b = 0 where a = 1 or a = 2

窗口2:更新数据,update test_innodb_lock set b = 3 where a = 3,发现阻塞,没有继续往下执行

由于还没有提交事务,并且使用了or导致索引失效,行级锁升级为表锁,窗口1只要没有提交事务,那么窗口2任何对test_innodb_lock表的操作都会阻塞,直到窗口1提交事务,窗口2才可以继续执行下去。

间隙锁的表现

假设有一张表,test_innodb_lock表有a和b二个字段,a字段里面的数据缺了2,4,6,8,这些就是间隙,这个间隙引发的锁就叫做间隙锁,一般发生在范围查询里面。

将mysql数据库改为手动提交

步骤1:

窗口1:更新数据,update test_innodb_lock set b = 5 where a >1 and a < 9

窗口2:更新数据,insert into test_innodb_lock values(4,4)发现阻塞了,没有继续往下执行。

窗口1进行了一个范围查询,会把a >1 and a < 9加上锁,窗口2这个会话想插入2,4,6,8是无法插入的,因为它已经被窗口1的会话持有了锁。

死锁

死锁是指两个或两个以上的事务在执行过程中,因争夺锁资源而造成的一种互相等待的现象。若无外力作用,事务都将无法推进下去。

解决死锁问题最简单的方式是不要有等待,将任何的等待都转化为回滚,并且事务重新开始。但是在线上环境中,这可能导致并发性能的下降,甚至任何一 个事务都不能进行。而这所带来的问题远比死锁问题更为严重,因为这很难被发现并且浪费资源。

解决死锁问题的另一种方法是超时,即当两个事务互相等待时,当一个等待时间超过设置的某一阈值时,其中一个事务进行回滚,另一个等待的事务就能继续进行。

在InnoDB存储引擎中,参数innodb_lock_wait_timeout用来设置超时的时间。 超时机制虽然简单,但是其仅通过超时后对事务进行回滚的方式来处理,或者说其是根据FIFO的顺序选择回滚对象。但若超时的事务所占权重比较大,如事务操作更新了很多行,占用了较多的undo log,这时采用FIFO的方式, 就显得不合适了,因为回滚这个事务的时间相对另一个事务所占用的时间可能会很多。

因此,除了超时机制,当前数据库还都普遍采用wait-for graph(等待图)的方式来进行死锁检测。较之超时的解决方案,这是一种更为主动的死锁检测方式。InnoDB存储引擎也采用的这种方式。wait-for graph要求数据库保存以下两种信息: 锁的信息链表和事务等待链表。

wait-for graph是一种较为主动的死锁检测机制,在每个事务请求锁并发生等待时都会判断是否存在回路,若存在则有死锁,通常来说InnoDB存储引擎选择回滚undo量最小的事务。

主键自增长实现原理

自增长在数据库中是非常常见的一种属性,也是很多DBA或开发人员首选的主键方式。在InnoDB存储引擎的内存结构中,对每个含有自增长值的表都有一个自增长计数器。

当对含有自增长的计数器的表进行插入操作时,这个计数器会被初始化,执行如下的语句来得到计数器的值:

SELECT MAX(auto_inc_col)FROM t FOR UPDATE;

插入操作会依据这个自增长的计数器值加1赋予自增长列。这个实现方式称做AUTO-INC Locking。

这种锁其实是采用一种特殊的表锁机制,为了提高插入的性能,锁不是在一个事务完成后才释放,而是在完成对自增长值插入的SQL语句后立即释放。

虽然AUTO-INCLocking从一定程度上提高了并发插入的效率,但还是存在一些性能上的问题。

首先,对于有自增长值的列的并发插入性能较差,事务必须等待前一个插入的完成(虽然不用等待事务的完成)。

其次,对于 INSERT...SELECT的大数据量的插入会影响插入的性能,因为另一个事务中的插入会被阻塞。

从MySQL5.1.22版本开始,InnoDB存储引擎中提供了一种轻量级互斥量的自增长实现机制,这种机制大大提高了自增长值插入的性能。并且从该版本开始,InnoDB存储引擎提供了一个参数innodb_autoinc_lock_mode来控制自增长的模式,该参数的默认值为1。

innodb_autoinc_lock_mode有三个选项:

0:是mysql5.1.22版本之前自增长的实现方式,通过表锁的AUTO-INCLocking方式实现的。

1:是默认值,对于简单的插入(插入之前就可以确定插入的行数),这个值会用互斥量去对内存中的计数器进行累加操作。对于批量插入(插入之前就不确定插入的行数),还是通过表锁的AUTO-INCLocking方式实现。在这种配置下,如果不考虑回滚操作,对于自增值的列,它的增长还是连续的,而且statemment-based方式的replication还是很好的工作。但是如果使用了AUTO-INCLocking方式去产生自增长的值,这个时候再进行简单插入操作,就需要等待AUTO-INCLocking释放。

2:在这个模式下,对于所有的插入的语句,它自增长值的产生都是通过互斥量,不是通过AUTO-INCLocking方式,这是性能最高的方式,但是如果是并发插入,在每次插入的时候,自增长的值就不是连续的,而且基于statemment-based replication会出现问题,所以在这个模式下,任何时候都要用row-base replication,这样才可以保证最大的并发性能和replication主从数据的一致。

statemment-based replication(SBR)和row-base replication(RBR)是主从复制的方式。

使用mysql自增长的坏处:

  • 强依赖DB。不同数据库语法和实现不同,数据库迁移的时候、多数据库版本支持的时候、或分表分库的时候需要处理,会比较麻烦。当DB异常时整个系统不可用,属于致命问题。

  • 单点故障。在单个数据库或读写分离或一主多从的情况下,只有一个主库可以生成。有单点故障的风险。

  • 数据一致性问题。配置主从复制可以尽可能的增加可用性,但是数据一致性在特殊情况下难以保证。主从切换时的不一致可能会导致重复发号。

  • 难于扩展。在性能达不到要求的情况下,比较难于扩展。ID发号性能瓶颈限制在单台MySQL的读写性能。

索引失效的几种情况

  • 如果条件中有or,即使其中有部分条件带索引也不会使用。

  • 对于复合索引,如果不使用前列,后续列也将无法使用。

  • like以%开头。列类型是字符串,那一定要在条件中将数据使用引号引用起来,否则不使用索引。

  • where中索引列有运算,有函数的,不使用索引。

  • 如果mysql觉得全表扫描更快的时候,数据少的情况下,不使用索引。

聚集索引

InnoDB存储引擎表是索引组织表,即表中数据按照主键顺序存放。而聚集索引(clusteredindex)就是按照每张表的主键构造一棵B+树,同时叶子节点中存放的即为整张表的行记录数据,也将聚集索引的叶子节点称为数据页。每个数据页都通过一个双向链表来进行链接由于实际的数据页只能按照一棵B+树进行排序,因此每张表只能拥有一个聚集索引。在多数情况下,查询优化器倾向于采用聚集索引。因为聚集索引能够在B+树索引的叶子节点上直接找到数据。此外,由于定义了数据的逻辑顺序,它对于主键的排序查找和范围查找速度非常快。叶子节点的数据就是用户所要查询的数据如:用户需要查询一张注册用户的表,查询最后注册的10位用户,由于B+树索引是双向链表的,用户可以快速找到最后一个数据页,并取出10条记录SELECT * FROM Profile ORDER BY id LIMIT 10;虽然使用ORDER BY对主键id记录进行排序,但是在实际过程中并没有进行所谓的filesort操作,而这就是因为聚集索引的特点。另一个是范围查询(range query),即如果要查找主键某一范围内的数据,通过叶子节点的上层中间节点就可以得到页的范围,之后直接读取数据页即可。如:SELECT * FROM Profile where id>1 and id < 100;

辅助索引

对于辅助索引(Secondary Index,也称非聚集索引),叶子节点并不包含行记录的全部数据。叶子节点除了包含键值以外,每个叶子节点中的索引行中还包含了一个书签(bookmark)。该书签用来告诉InnoDB存储引擎哪里可以找到与索引相对应的行数据。由于InnoDB存储引擎表是索引组织表,因此InnoDB存储引擎的辅助索引的书签就是相应行数据的聚集索引键。辅助索引的存在并不影响数据在聚集索引中的组织,因此每张表上可以有多个辅助索引。当通过辅助索引来寻找数据时,InnoDB存储引擎会遍历辅助索引并通过叶级别的指针获得指向主键索引的主键,然后再通过主键索引来找到一个完整的行记录。举例来说,如果在一棵高度为3的辅助索引树中查找数据,那需要对这棵辅助索引树遍历3次找到指定主键,如果聚集索引树的高度同样为3,那么还需要对聚集索引树进行3次查找,最终找到一个完整的行数据所在的页,因此一共需要6次逻辑IO访问以得到最终的一个数据页。

覆盖索引,什么情况下优化器会选择使用覆盖索引

InnoDB存储引擎支持覆盖索引(covering index,或称索引覆盖),即从辅助索引中就可以得到查询的记录(此时不能够使用select * 操作,只能对特定的索引字段进行select),而不需要查询聚集索引中的记录。使用覆盖

索引的一个好处是辅助索引不包含整行记录的所有信息,故其大小要远小于聚集索引,因此可以减少大量的IO操作。对于InnoDB存储引擎的辅助索引而言,由于其包含了主键信息,因此其叶子节点存放的数据为(primarykey1,primary key2,...,key1,key2,...)。例如,下列语句都可仅使用一次辅助联合索引来完成查询:

java 复制代码
SELECT key2 FROM table WHERE key1=xxx;
SELECT primary key2,key2 FROM table WHERE key1=xxx;
SELECT primary key1,key2 FROM table WHERE key1=xxx;
SELECT primary key1,primary key2,key2 FROM table WHERE key1=xxx;
java 复制代码
CREATETABLEbuy_log(
userid INT UNSIGNED NOT NULL,
buy_date DATE
)ENGINE=InnoDB;
ALTER TABLE buy_log ADD KEY (userid);
ALTER TABLE buy_log ADD KEY (userid,buy_date);

覆盖索引的另一个好处是对某些统计问题而言的。还是对于上题创建的表buy_log,要进行举例说明。
SELECT COUNT(*) FROM buy_log;InnoDB存储引擎并不会选择通过查询聚集索引来进行统计。由于buy_log表上还有辅助索引,而辅助索引远小于聚集索引,选择辅助索引可以减少IO操作。

在通常情况下,诸如(a,b)的联合索引,一般是不可以选择列b中所谓的查询条件。但是如果是统计操作,并且是覆盖索引的,则优化器会进行选择,如下述语句:SELECT COUNT(*)FROM buy_log WHERE buy_date>='2011-01-01'ANDbuy_date<'2011-02-01'

表buy_log有(userid,buy_date)的联合索引,这里只根据列b进行条件查询,一般情况下是不能进行该联合索引的,但是这句SQL查询是统计操作,并且可以利用到覆盖索引的信息,因此优化器会选择该联合索引.

联合索引

联合索引是指对表上的多个列进行索引。

java 复制代码
CREATE TABLE buy_log(
userid INT UNSIGNED NOT NULL,
buy_date DATE
)ENGINE=InnoDB;
ALTER TABLE buy_log ADD KEY (userid);
ALTER TABLE buy_log ADD KEY (userid,buy_date);

以上代码建立了两个索引来进行比较。两个索引都包含了userid字段。

情况1: 如果只对于userid进行查询,如:SELECT * FROM buy_log WHERE userid=2;索引选择:优化器最终的选择是索引userid,因为该索引的叶子节点包含单个键值,所以理论上一个页能存放的记录应该更多。

情况2:SELECT * FROM buy_log WHERE userid=1 ORDER BY buy_date DESC LIMIT 3;索引选择:优化器使用了(userid,buy_date)的联合索引userid_2,因为在这个联合索引中buy_date已经排序好了。根据该联合索引取出数据,无须再对buy_date做一次额外的排序操作。

情况 3:假如三个字段的联合索引。如:对于联合索引(a,b,c)来说,下列语句同样可以直接通过联合索引得到结果,不需要filesort的排序操作:

java 复制代码
SELECT...FROM TABLE WHERE a=xxx ORDER BY b
SELECT...FROM TABLE WHERE a=xxx AND b=xxx ORDER BY c

但是对于下面的语句,联合索引不能直接得到结果,其还需要执行一次filesort排序操作,因为索引(a,c)并未排序:

java 复制代码
SELECT...FROM TABLE WHERE a=xxx ORDER BY c

索引总结

聚集索引的叶子节点称为数据页,每个数据页通过一个双向链表来进行链接,而且数据页按照主键的顺序进行排列。每个数据页上存放的是完整的行记录,而在非数据页的索引页中,存放的仅仅是键值及指向数据页的偏移量,而不是一个完整的行记录。如果定义了主键,InnoDB会自动使用主键来创建聚集索引。如果没有定义主键,InnoDB会选择一个唯一的非空索引代替主键。如果没有唯一的非空索引,InnoDB会隐式定义一个主键来作为聚集索引。

辅助索引它叶子节点中没有行记录的全部数据,叶子节点除了包含键值以外,每个叶子节点的索引行还包含了一个书签,该书签用来告诉InnoDB哪里可以找到与索引相对应的行数据。

覆盖索引先遍历辅助索引,再遍历聚集索引,而如果要查询的字段值在辅助索引上就有,就不用再查聚集索引了,这显然会减少IO操作。

联合索引,它是对表上的多个列进行索引,键值都是排序的,通过叶子节点可以顺序的读出所有数据,联合索引的好处在于能起到"一个顶三个"的作用。比如建了一个(a,b,c)的复合索引,那么实际等于建了(a),(a,b),(a,b,c)三个索引,每多一个索引,都会增加写操作的开销和磁盘空间的开销,对于大数据的表,这是不小的开销。另外它还可以避免filesort排序,因为filesort的过程,一行数据会被读两次,第一次是where条件过滤时,第二个是排完序后还得用行指针去读一次。

SQL的执行流程

第一步,先连接到这个数据库上,这时候接待你的就是连接器。连接器负责跟客户端建立连接、获取权限、维持和管理连接。用户名密码认证通过,连接器会到权限表里面查出你拥有的权限。一个用户成功建立连接后,即使你用管理员账号对这个用户的权限做了修改,也不会影响已经存在连接的权限。修改完成后,只有再新建的连接才会使用新的权限设置。连接完成后,如果你没有后续的动作,这个连接就处于空闲状态,你可以在 show processlist 命令中看到它。客户端如果长时间不发送command到Server端,连接器就会自动将它断开。这个时间是由参数 wait_timeout 控制的,默认值是 8 小时。

第二步:查询缓存。MySQL 拿到一个查询请求后,会先到查询缓存看看,之前是不是执行过这条语句。之前执行过的语句及其结果可能会以 key-value对的形式,被直接缓存在内存中。key 是查询的语句,value 是查询的结果。如果你的查询能够直接在这个缓存中找到 key,那么这个value就会被直接返回给客户端。如果语句不在查询缓存中,就会继续后面的执行阶段。执行完成后,执行结果会被存入查询缓存中。你可以看到,如果查询命中缓存,MySQL不需要执行后面的复杂操作,就可以直接返回结果,这个效率会很高。大多数情况查询缓存就是个鸡肋,为什么呢?因为查询缓存往往弊大于利。查询缓存的失效非常频繁,只要有对一个表的更新,这个表上所有的查询缓存都会被清空。因此很可能你费劲地把结果存起来,还没使用呢,就被一个更新全清空了。对于更新压力大的数据库来说,查询缓存的命中率会非常低。这个鸡肋也有地方可以去使用它,比如说不会改变的表数据,极少更新的表,像一些系统配置表、字典表,全国的省份之类的表,这些表上的查询适合使用查询缓存。MySQL提供了这种"按需使用"的方式,可以将my.cnf参数query_cache_type 设置成2,query_cache_type有3个值:0代表关闭查询缓存,1代表开启,2代表当sql语句中有SQL_CACHE关键词时才缓存。确定要使用查询缓存的语句,用 SQL_CACHE显式指定,比如,select SQL_CACHE * from user where ID=5;

第三步,如果没有命中查询缓存,就要开始真正执行语句了。MySQL 需要知道你要做什么,需要对 SQL 语句做解析。

分析器先会做"词法分析",你输入的一条 SQL 语句,MySQL需要识别出里面的字符串分别是什么,代表什么。MySQL从你输入的"select"这个关键字识别出来,这是一个查询语句。它也要把字符串"user"识别成"表名 user",把字符串"ID"识别成"列 ID"。做完了这些识别以后,就要做"语法分析"。根据词法分析的结果,语法分析器会根据语法规则,判断你输入的这个 SQL语句是否满足MySQL语法。如果你的语句不对,就会收到"您的SQL语法有错误"的错误提醒。语句正确之后,会丢到分析机里面执行分析,语法分析由Bison生成,经过bison语法分析之后,会生成一个语法树。比如,你的操作是select还是insert,你需要对那些字段进行操作,作用在哪张表上面,条件是什么。

经过了分析器,MySQL就知道这些字符串代表什么,要做什么了。在开始执行之前,还要先经过优化器的处理。

第四步,优化器,在表里面有多个索引的时候,决定使用哪个索引;或者在一个语句有多表关联的时候,优化器可以决定各个表的连接顺序,同一条多表查询的sql,执行的方案会有多种,比如,select * from user1 join user2 on user1.id = user2.id where user1.name=liaozhiwei and user2.name=haoshuai;既可以先从表user1 里面取出 name=liaozhiwei的 ID 值,再根据 ID 值关联到表user2,再判断user2 里面 name的值是否等于liaozhiwei。也可以先从表user2 里面取出 name=haoshuai的 ID 值,再根据 ID 值关联到user1,再判断user1 里面 name 的值是否等于haoshuai。这两种执行方法的逻辑结果是一样的,但是执行的效率会有不同,而优化器的作用就是决定选择使用哪一个方案。执行方案就确定下来了,然后进入执行器阶段。

第五步,开始执行的时候,要先判断一下你对这个表有没有执行查询的权限,如果没有,就会返回没有权限的错误。如果有权限,就打开表继续执行。打开表的时候,执行器就会根据表的引擎定义,去使用这个引擎提供的接口。比如,我有一条sql:select * from user where id=10;执行器调用 InnoDB 引擎接口取这个表的第一行,判断 ID 值是不是10,如果不是则跳过, 调用引擎接口取"下一行",重复相同的判断逻辑,直到取到这个表的最后一行,如果是将这行保存在结果集中。执行器将遍历过程中所有满足条件的行组成的记录集作为结果集返回给客户端。到这一步,这个语句就执行完成了。

MySQL调优

表结构设计

在进行数据库设计时,开发者需要关注表的规划。

首先,开发者要了解MySQL数据库的页大小。当表中的单行数据达到16KB时,这意味着表中只能存储一条数据,这对于数据库来说是不合理的。MySQL数据库将数据从磁盘读取到内存,它使用磁盘块作为基本单位进行读取。如果一个数据块中的数据一次性被读取,那么查询效率将会提高。

以InnoDB存储引擎为例,它使用页作为数据读取单位。页是磁盘管理的最小单位,默认大小为16KB。由于系统的磁盘块存储空间通常没有这么大,InnoDB在申请磁盘空间时会使用多个地址连续的磁盘块来达到页的大小16KB。

查询数据时,一个页中的每条数据都能帮助定位到数据记录的位置,从而减少磁盘I/O操作,提高查询效率。InnoDB存储引擎在设计时会将根节点常驻内存,尽力使树的深度不超过3。这意味着在查询过程中,I/O操作不超过3次。树形结构的数据可以让系统高效地找到数据所在的磁盘块。

在这里讨论一下B树和B+树的区别。B树的结构是每个节点既包含key值也包含value值,而每个页的存储空间是16KB。如果数据较大,将会导致一个页能存储数据量的数量很小。相比之下,B+树的结构是将所有数据记录节点按照键值大小顺序存放在同一层的叶子节点上,而非叶子节点上只存储key值信息。这样可以大大加大每个节点存储的key值数量,降低B+树的高度。

通过了解MySQL数据库底层存储的原理和数据结构,开发者在设计表时应该尽量减少单行数据的大小,将字段宽度设置得尽可能小。

在设计表时,开发者要注意以下几点以提高查询速度和存储空间利用率:

(1)避免使用text、Blob、Clob等大数据类型,它们占用的存储空间更大,读取速度较慢。

(2)尽量使用数字型字段,如性别字段用0/1的方式表示,而不是男女。这样可以控制数据量,增加同一高度下B+树容纳的数据量,提高检索速度。

(3)使用varchar/nvarchar代替char/nchar。变长字段存储空间较小,可以节省存储空间。

(4)不在数据库中存储图片、文件等大数据,可以通过第三方云存储服务存储,并提供图片或文件地址。

(5)金额字段使用decimal类型,注意长度和精度。如果存储的数据范围超过decimal的范围,建议将数据拆成整数和小数分开存储。

(6)避免给数据库留null值。尤其是时间、整数等类型,可以在建表时就设置非空约束。NULL列会使用更多的存储空间,在MySQL中处理NULL值也更复杂。为NULL的列可能导致固定大小的索引变成可变大小的索引,例如只有整数列的索引。

建索引

在建立索引时,需要权衡数据的维护速度和查询性能。以下是一些关于如何确定是否为表中字段建立索引的示例:

(1)对于经常修改的数据,建立索引会降低数据维护速度,因此不适合对这些字段建立索引,例如状态字段。

(2)对于性别字段,通常用0和1表示,但由于其区分度不高(100万用户中90万为男性,10万为女性),因此一般不需要建立索引。然而,如果性别字段的区分度非常高(例如90万男性和10万女性),而且该字段不经常更改,则可以考虑为该字段建立索引。

(3)可以在where及order by涉及的列上建立索引。

(4)对于需要查询排序、分组和联合操作的字段,适合建立索引,以提高查询性能。

(5)索引并非越多越好,一个表的索引数最好不要超过6个。当为多个字段创建索引时,表的更新速度会减慢,因此应选择具有较高区分度且不经常更改的字段创建索引。

(6)尽量让字段顺序与索引顺序一致,复合索引中的第一个字段作为条件时才会使用该索引。

(7)遵循最左前缀原则:尽量确保查询中的索引列按照最左侧的列进行匹配。

SQL优化

为了优化SQL语句,需要了解数据库的架构、索引、查询优化器以及各种SQL执行引擎的机制等技术知识。

SQL编写

在编写SQL语句时,开发者需要注意一些关键点以提高查询性能。以下是一些建议:

(1)避免在WHERE子句中对查询的列执行范围查询(如NULL值判断、!=、<>、or作为连接条件、IN、NOT IN、LIKE模糊查询、BETWEEN)和使用"="操作符左侧进行函数操作、算术运算或表达式运算,因为这可能导致索引失效,从而导致全表扫描。

(2)对于JOIN操作,如果数据量较大,先分页再JOIN可以避免大量逻辑读,从而提高性能。

(3)使用COUNT()可能导致全表扫描,如有WHERE条件的SQL,WHERE条件字段未创建索引会进行全表扫描。COUNT()只统计总行数,聚簇索引的叶子节点存储整行记录,非聚簇索引的叶子节点存储行记录主键值。非聚簇索引比聚簇索引小,选择最小的非聚簇索引扫表更高效。

(4)当数据量较大时,查询只返回必要的列和行,LIMIT 分页限制返回的数据,减少请求的数据量,插入建议分批次批量插入,以提高性能。

(5)对于大连接的查询SQL,由于数据量较多、又是多表,容易出现整个事务日志较大,消耗大量资源,从而导致一些小查询阻塞,所以优化方向是将它拆分成单表查询,在应用程序中关联结果,这样更利于高性能可伸缩,同时由于是单表减少了锁竞争效率上也有一定提升。

(6)尽量明确只查询所需列,避免使用SELECT *。SELECT *会导致全表扫描,降低性能。若必须使用SELECT *,可以考虑使用MySQL 5.6及以上版本,因为这些版本提供了离散读优化(Discretized Read Optimization),将离散度高的列放在联合索引的前面,以提高性能。

索引下推(ICP,Index Condition Pushdown)优化:ICP优化将部分WHERE条件的过滤操作下推到存储引擎层,减少上层SQL层对记录的索取,从而提高性能。在某些查询场景下,ICP优化可以大大减少上层SQL层与存储引擎的交互,提高查询速度。

多范围读取(MRR,Multi-Range Read)优化:MRR优化将磁盘随机访问转化为顺序访问,提高查询性能。当查询辅助索引时,首先根据结果将查询得到的索引键值存放于缓存中。然后,根据主键对缓存中的数据进行排序,并按照排序顺序进行书签查找。

这种顺序查找减少了对缓冲池中页的离散加载次数,可以提高批量处理对键值查询操作的性能。

在编写SQL时,使用EXPLAIN语句观察索引是否失效是个好习惯。索引失效的原因有以下几点:

(1)如果查询条件中包含OR,即使其中部分条件带有索引,也无法使用。

(2)对于复合索引,如果不使用前列,后续列也无法使用。

(3)如果查询条件中的列类型是字符串,则在条件中将数据使用引号引用起来非常重要,否则索引可能失效。

(4)如果在查询条件中使用运算符(如+、-、*、/等)或函数(如substring、concat等),索引将无法使用。

(5)如果MySQL认为全表扫描比使用索引更快,则可能不使用索引。在数据较少的情况下尤其如此。

SQL优化工具

常用的SQL优化方法包括:业务层逻辑优化、SQL性能优化、索引优化。

业务层逻辑优化:开发者需要重新梳理业务逻辑,将大的业务逻辑拆分成小的逻辑块,并行处理。这样可以提高处理效率,降低数据库的访问压力。

SQL性能优化:除了编写优化的SQL语句、创建合适的索引之外,还可以使用缓存、批量操作减少数据库的访问次数,以提高查询效率。

索引优化:对于复杂的SQL语句,人工直接介入调节可能会增加工作量,且效果不一定好。开发者的索引优化经验参差不齐,因此需要使用索引优化工具,将优化过程工具化、标准化。最好是在提供SQL语句的同时,给出索引优化建议。

慢SQL优化

影响程度一般的慢查询通常在中小型企业因为项目赶进度等问题常被忽略,对于大厂基本由数据库管理员通过实时分析慢查询日志,对比历史慢查询,给出优化建议。

影响程度较大的慢查询通常会导致数据库负载过高,人工故障诊断,识别具体的慢查询SQL,及时调整,降低故障处理时长。

当前未被定义为慢查询的SQL可能随时间演化为慢查询,对于核心业务,可能引发故障,需分类接入:

(1)未上线准慢查询:需要通过发布前集成测试流水线,通常都是经验加上explain关键字识别慢查询,待解决缺陷后才能发布上线。

(2)已上线准慢查询:表数据量增加演变为慢查询,比较常见,通常会变成全表扫描,开发者可以增加慢查询配置参数log_queries_not_using_indexes记录至慢日志,实时跟进治理。

数据分区

在面对大量数据时,分区可以帮助提高查询性能。分区主要分为两类:表分区和分区表。

表分区

表分区是在创建表时定义的,需要在表建立的时候创建规则。如果要修改已有的有规则的表分区,只能新增,不能随意删除。表分区的局限性在于单个MySQL服务器支持1024个分区。

分区表

当表分区达到上限时,可以考虑垂直拆分和水平拆分。垂直拆分将单表变为多表,以增加每个分区承载的数据量。水平拆分则是将数据按照某种策略拆分为多个表。

垂直分区的优点是可以减少单个分区的数据量,从而提高查询性能。但缺点是需要考虑数据的关联性,并在SQL查询时进行反复测试以确保性能。

对于包含大文本和BLOB列的表,如果这些列不经常被访问,可以将它们划分到另一个分区,以保证数据相关性的同时提高查询速度。

水平分区

随着数据量的持续增长,需要考虑水平分区。水平分区有多种模式,例如:

(1)范围(Range)模式:允许DBA将数据划分为不同的范围。例如DBA可以将一个表按年份划分为三个分区,80年代的数据、90年代的数据以及2000年以后的数据。

(2)哈希(Hash)模式:允许DBA通过对表的一个或多个列的Hash Key进行计算,最后通过这个Hash码不同数值对应的数据区域进行分区。例如DBA可以建立一个根据主键进行分区的表。

(3)列表(List)模式:允许系统通过DBA定义列表的值所对应行数据进行分割。例如DBA建立了一个横跨三个分区的表,分别根据2021年、2022年和2023年的值对应数据。

(4)复合模式(Composite):允许将多个模式组合使用,如在初始化已经进行了Range范围分区的表上,可以对其中一个分区再进行Hash哈希分区。

灾备处理

在MySQL中,冷热备份可以帮助 开发者在不影响性能的情况下确保数据的安全性。

冷备份

当某些数据不再需要或不常访问时,可以考虑进行冷备份。冷备份是在数据库关闭时进行的数据备份,速度更快,安全性也相对更高。例如您可以将一个不再需要的月度报告数据备份到外部存储设备,以确保在需要时可以轻松访问这些数据。

热备份

对于需要实时更新的数据,可以考虑热备份。热备份是在应用程序运行时进行的数据备份,备份的是数据库中的SQL操作语句。例如您可以将用户的购物记录备份到一个在线存储服务中,以便在需要时可以查看这些数据。

冷备份与热备份的权衡

(1)冷备份速度更快,因为它不涉及应用程序的运行,但可能需要外部存储设备。

(2)热备份速度较慢,因为它涉及应用程序的运行和数据库操作的记录。

(3)冷备份更安全,因为它在数据库关闭时进行,不受应用程序影响。

(4)热备份安全性稍低,因为它在应用程序运行时进行,需要保持设备和网络环境的稳定性。

备份注意事项

(1)备份过程中要保持设备和网络环境稳定,避免因中断导致数据丢失。

(2)备份时需要仔细小心,确保备份数据的正确性,以防止恢复过程中出现问题。

(3)热备份操作要特别仔细,备份SQL操作语句时不能出错。

总之,通过对冷热数据进行备份,可以在不影响应用程序性能的情况下确保数据的安全性。在实际应用中,应根据数据的需求和业务场景选择合适的备份策略。

高可用

在生产环境中,MySQL的高可用性变得越来越重要,因为它是一个核心的数据存储和管理系统,任何错误或中断都可能导致严重的数据丢失和系统瘫痪。因此,建立高可用的MySQL环境是至关重要的。

MMM

用于监控和故障转移MySQL集群。它使用虚拟IP(VIP)机制实现集群的高可用。集群中,主节点通过一个虚拟IP地址提供数据读写服务,当出现故障时,VIP会从原主节点漂移到其他节点,由这些节点继续提供服务。双主故障切换(MMM)的主要缺点是故障转移过程过于简单粗暴,容易丢失事务,因此建议采用半同步复制以降低失败概率。

MHA

它是一种用于故障切换的工具,能在30秒内完成故障切换,并在切换过程中最大程度地保证数据一致性。高可用性与可伸缩性(MHA)主要监控主节点的状态,当检测到主节点故障时,它会提升具有最新数据的从节点成为新的主节点,并通过其他从节点获取额外信息来避免数据一致性方面的问题。MHA可以单独部署,分为Manager节点和Node节点,分别部署在单独的机器上和每台MySQL机器上。Node节点负责解析MySQL日志,而Manager节点负责探测Node节点并判断各节点的运行状况。当检测到主节点故障时,Manager节点会直接提升一个从节点为新主节点,并让其他从节点挂载到新主节点上,实现完全透明。为了降低数据丢失的风险,建议使用MHA架构。

MGR

它是MySQL官方在5.7.17版本中正式推出的一种组复制机制,主要用于解决异步复制和半同步复制中可能产生的数据不一致问题。组复制(MGR)由若干个节点组成一个复制组,事务提交后,必须经过超过半数节点的决议并通过后才能提交。引入组复制主要是为了解决传统异步复制和半同步复制可能出现的数据不一致问题。

组复制的主要优点是基本无延迟,延迟较异步复制小很多,且具有数据强一致性,可以保证事务不丢失。然而,它也存在一些局限性:

(1)仅支持InnoDB存储引擎。

(2)表必须具有主键。

(3)仅支持GTID模式,日志格式为row格式。

异常发现处理

在使用MySQL时,可能会遇到各种异常情况,例如连接错误、查询错误、数据删除错误等等。在处理这些异常情况时,开发人员需要了解异常的原因和处理方法,以便及时排除问题,保障系统的稳定性和可靠性。

数据库监控

及时将数据库异常通过短信、邮件、微信等形式通知给管理员,并且可以将数据库运行的实时指标统计分析图表显示出来,便于更好地对数据库进行规划和评估,目前市面上比较主流的数据库监控工具有Prometheus + Grafana + mysqld_exporter(比较受欢迎)、SolarWinds SQL Sentry、Database Performance Analyzer、OpenFalcon。

数据库日志

在MySQL中,有一些关键的日志可以用作异常发现并通过这些日志给出解决方案:

(1)重做日志(redo log):记录物理级别的页修改操作,例如页号123、偏移量456写入了"789"数据。可以通过"show global variables like 'innodb_log%';"命令查看。主要用于事务提交时保证事务的持久性和回滚。

(2)回滚日志(undo log):记录逻辑操作日志,例如添加一条记录时会记录一条相反的删除操作。可以通过"show variables like 'innodb_undo%';"命令查看。主要用于保证事务的原子性,在需要时回滚事务。

(3)变更日志/二进制日志(bin log):记录数据库执行的数据定义语句(DDL)和数据操作语句(DML)等操作。例如数据库意外挂机时,可以通过二进制日志文件查看用户执行的命令,并根据这些操作指令恢复数据库或将数据复制到其他数据库中。可以通过"show variables like '%log_bin%';"命令查看。主要用于性能优化和复制数据。

(4)慢查询日志:记录响应时间超过指定阈值的SQL语句。主要用于性能优化。可以通过"show variables like '%slow_query_log%';"命令查看。

(5)错误日志:记录MySQL服务启动、运行、停止时的诊断信息、错误信息和警告提示。主要用于排查MySQL服务出现异常的原因。可以通过"SHOW VARIABLES LIKE 'log_err%';"命令查看。

(6)通用查询日志:记录用户的所有操作,无论是所有的SQL语句还是调整MySQL参数或者启动和关闭MySQL都会记录。可以还原操作的场景。通过SHOW VARIABLES LIKE '%general%';命令查看。

(7)中继日志(relay log):只存在主从数据库的从数据库上,用于主从同步,可以在xx-relaybin.index索引文件和-relaybin.0000x数据文件查看。

(8)数据定义语句日志(ddl.log):记录数据定义的SQL,比如ALTER TABLE。

(9)processlist日志:查看正在执行的sql语句。

(10) innodb status日志:查看事务、锁、缓冲池和日志文件,主要用于诊断数据库性能。

数据库巡检

巡检工作保障系统平稳有效运行,比如飞机起飞巡检保证起飞后能够正常工作。巡检工作主要由数据库管理员和后端开发工程师负责。

数据库管理员主要负责处理数据库基础功能/高可用/备份/中间件/报警组件、集群拓扑、核心参数等集群层面的隐患、服务器硬件层面隐患,对于磁盘可用空间预测等范围。

后端开发工程师主要负责库表设计缺陷、数据库使用不规范等引起的业务故障或性能问题的隐患,定期采集整型字段值有没有超过最大值,因为整型类型的字段保存的数值有上限。对于读写情况需要定期观察表大小,找出有问题的大表进行优化调整。

资源评估

测试人员进行压测,观察极限环境下数据库各项指标是否正常工作,运维工程师或者数据库管理员对数据容量进行评估,服务器资源需要提前规划,同时设置预警通知,超过阈值安排相关人员进行扩容,从而保证数据库稳定运行。

数据服务

数据服务的主要目的是帮助用户规划和迁移数据,备份和恢复数据库以及进行数据校验等功能,以确保用户的数据始终处于安全可靠的状态。

子表结构生成

一个表进行拆分,会根据业务实际情况进行拆解,例如用户表可以根据地区拆分tb_user可拆分成上海地区的用户表(tb_user_sh)、广州地区的用户表(tb_user_gz),那么全国有很多个城市,每个地方都需要创建一张子表并且维护它会比较费时费力,通常情况下,会开发3个接口做表结构同步:根据主表创建子表、主表字段同步到子表、主表索引同步子表。

下面对这3个接口提供思路以及关键代码。

主表创建子表,代码如下:

java 复制代码
/**
* {
*     "tableName": "tb_user",
*     "labCodes": [
*         "sh",//上海
*         "gz"//广州
*     ]
* }     
*/
public Boolean createTable(ConfigReq reqObject) {
	if (CollectionUtils.isEmpty(reqObject.getLabCodes())) {
		return false;
	}
	List<String> labCodes = reqObject.getLabCodes();
	for (String labCode: labCodes){
		//主表表名
		String tableName = reqObject.getTableName();
		//子表后表名
		String newTable = String.format("%s_%s", tableName, labCode);
		//校验子表是否存在
		Integer checkMatrix = configExtMapper.checkTable(newTable);
		if(checkMatrix == null || checkMatrix.intValue() < 0){
		//创建子表结构
		configExtMapper.createConfigTable(tableName, newTable);
		}
		}
	return true;
}

主表字段同步到子表,代码如下:

java 复制代码
/**
* 主表字段同步到子表
* @param masterTable 主表
* @return
*/
private Boolean syncAlterTableColumn(String masterTable) {
	String table = masterTable + "%";
	//获取子表名
	List<String> tables = configExtMapper.getTableInfoList(table);
	if(CollectionUtils.isEmpty(tables)){
		return false;
	}
	//获取主表结构列信息
	List<ColumnInfo> masterColumns = configExtMapper.getColumnInfoList(masterTable);
	if (masterColumns.isEmpty()){
		return false;
	}
	String alterName = null;
	for (ColumnInfo column: masterColumns) {
		column.setAlterName(alterName);
		alterName = column.getColumnName();
	}
	for(String tableName : tables){
		if(StringUtils.equalsIgnoreCase(tableName, masterTable)){
			continue;
		}
		//获取子表结构列信息
		List<ColumnInfo> columns = configExtMapper.getColumnInfoList(tableName);
		if(CollectionUtils.isEmpty(columns)){
			continue;
		}
		for (ColumnInfo masterColumn : masterColumns) {
			ColumnInfo column = columns.stream().filter(c -> StringUtils.equalsIgnoreCase(c.getColumnName(),
			masterColumn.getColumnName())).findFirst().orElse(null);
			if (column == null){
				column = new ColumnInfo();
				column.setColumnName(masterColumn.getColumnName());//列名
				column.setAddColumn(true);//是否修改
			}
			if (column.hashCode() == masterColumn.hashCode()){
				continue;
			}
			column.setTableName(tableName);//表名
			column.setColumnDef(masterColumn.getColumnDef());//是否默认值
			column.setIsNull(masterColumn.getIsNull());//是否允许为空(NO:不能为空、YES:允许为空)
			column.setColumnType(masterColumn.getColumnType());//字段类型(如:varchar(512)、text、bigint(20)、datetime)
			column.setComment(masterColumn.getComment());//字段备注(如:备注)
			column.setAlterName(masterColumn.getAlterName());//修改的列名
			//创建子表字段
			configExtMapper.alterTableColumn(column);
		}
	}
	return true;
}

主表索引同步子表,代码如下:

java 复制代码
/**
* 主表索引同步子表
* @param masterTableName 主表名
* @return
*/
private Boolean syncAlterConfigIndex(String masterTableName) {
	String table = masterTableName + "%";
	//获取子表名
	List<String> tableInfoList = configExtMapper.getTableInfoList(table);
	if (tableInfoList.isEmpty()){
		return false;
	}
	// 获取所有索引
	List<String> allIndexFromTableName = configExtMapper.getAllIndexNameFromTableName(masterTableName);
	if (CollectionUtils.isEmpty(allIndexFromTableName)) {
		return false;
	}
	for (String indexName : allIndexFromTableName) {
		//获取拥有索引的列名
		List<String> indexFromIndexName = configExtMapper.getAllIndexFromTableName(masterTableName, indexName);
		for (String tableName : tableInfoList) {
			if (!tableName.startsWith(masterTableName)) {
				continue;
			}
			//获取索引名称
			List<String> addIndex = configExtMapper.findIndexFromTableName(tableName, indexName);
			if (CollectionUtils.isEmpty(addIndex)) {
				//创建子表索引
				configExtMapper.commonCreatIndex(tableName, indexName, indexFromIndexName);
			}
		}
	}
	return true;
}

子表结构生成的SQL,代码如下:

java 复制代码
<!--校验子表是否存在 这里db_user写死了数据库名称,后面可以根据实际情况调整-->
<select id="checkTable" resultType="java.lang.Integer" >
	SELECT 1 FROM INFORMATION_SCHEMA.`TABLES` WHERE TABLE_SCHEMA = 'db_user' AND TABLE_NAME = ##{tableName};
</select>
<!--创建子表结构-->
<update id="createConfigTable" >
	CREATE TABLE `${newTableName}` LIKE `${sourceName}`;
</update>
<!--获取子表名-->
<select id="getTableInfoList" resultType="java.lang.String">
	SELECT `TABLE_NAME`
	FROM INFORMATION_SCHEMA.`TABLES`
	WHERE `TABLE_NAME` LIKE ##{tableName};
</select>
<!--获取主/子表结构列信息 这里db_user写死了数据库名称,后面可以根据实际情况调整-->
<select id="getColumnInfoList" resultType="com.yunxi.datascript.config.ColumnInfo">
	SELECT `COLUMN_NAME` AS columnName
	,COLUMN_DEFAULT AS columnDef   -- 是否默认值
	,IS_NULLABLE AS isNull 		-- 是否允许为空
	,COLUMN_TYPE AS columnType		-- 字段类型
	,COLUMN_COMMENT AS comment	    -- 字段备注
	FROM INFORMATION_SCHEMA.`COLUMNS`
	WHERE TABLE_SCHEMA = 'db_user'
	AND `TABLE_NAME` = ##{tableName}
	ORDER BY ORDINAL_POSITION ASC;
</select>
<!--创建子表字段-->
<update id="alterTableColumn" parameterType="com.yunxi.datascript.config.ColumnInfo">
	ALTER TABLE `${tableName}`
	<choose>
		<when test="addColumn">
			ADD COLUMN
		</when >
		<otherwise>
			MODIFY COLUMN
		</otherwise>
	</choose>
	${columnName}
	${columnType}
	<choose>
		<when test="isNull != null and isNull == 'NO'">
			NOT NULL
		</when >
		<otherwise>
			NULL
		</otherwise>
	</choose>
	<if test="columnDef != null and columnDef != ''">
		DEFAULT ##{columnDef}
	</if>
	<if test="comment != null and comment != ''">
		COMMENT ##{comment}
	</if>
	<if test="alterName != null and alterName != ''">
		AFTER ${alterName}
	</if>
</update>
<!--获取所有索引-->
<select id="getAllIndexNameFromTableName" resultType="java.lang.String">
	SELECT DISTINCT index_name FROM information_schema.statistics WHERE table_name = ##{tableName} AND index_name != 'PRIMARY'
</select>
<!--获取拥有索引的列名-->
<select id="getAllIndexFromTableName" resultType="java.lang.String">
	SELECT COLUMN_NAME FROM information_schema.statistics WHERE table_name = ##{tableName} AND index_name = ##{idxName} AND index_name != 'PRIMARY'
</select>
<!--获取索引名称-->
<select id="findIndexFromTableName" resultType="java.lang.String">
	SELECT index_name FROM information_schema.statistics WHERE table_name = ##{tableName} AND index_name = ##{idxName}
</select>
<!--创建子表索引-->
<update id="commonCreatIndex">
	CREATE INDEX ${idxName} ON `${tableName}`
	<foreach collection="list" item="item" open="(" close=")" separator=",">
		`${item}`
	</foreach>;
</update>

根据以上关键代码以及实现思路结合实际情况开发出3个接口足以满足日常分表需求了。

数据迁移

数据迁移通常有两种情况:

第一种是开发人员编码,将数据从一个数据库读取出来,再将数据异步的分批次批量插入另一个库中。

第二种是通过数据库迁移工具,通常使用Navicat for MySQL就可以实现数据迁移。

数据迁移需要注意的是不同数据库语法和实现不同,数据库版本不同,分库分表时数据库的自增主键ID容易出现重复键的问题,通常情况下会在最初需要自增时考虑分布式主键生成策略。

数据校验

数据校验有对前端传入的参数进行数据校验、有程序插入数据库中的数据进行校验,比如非空校验、长度校验、类型校验、值的范围校验等、有对数据迁移的源数据库和目标数据库的表数据进行对比、这些都是保证数据的完整性。

读写分离

MySQL读写分离是数据库优化的一种手段,通过将读和写操作分离到不同的数据库服务器上,可以提高数据库的读写性能和负载能力。

主从数据同步

业务应用发起写请求,将数据写到主库,主库将数据进行同步,同步地复制数据到从库,当主从同步完成后才返回,这个过程需要等待,所以写请求会导致延迟,降低吞吐量,业务应用的数据读从库,这样主从同步完成就能读到最新数据。

中间件路由

业务应用发起写请求,中间件将数据发往主库,同时记录写请求的key(例如操作表加主键)。当业务应用有读请求过来时,如果key存在,暂时路由到主库,从主库读取数据,在一定时间过后,中间件认为主从同步完成,就会删除这个key,后续读将会读从库。

缓存路由

缓存路由和中间件路由类似,业务应用发起写请求,数据发往主库,同时缓存记录操作的key,设置缓存的失效时间为主从复制完成的延时时间。如果key存在,暂时路由到主库。如果key不存在,近期没发生写操作,暂时路由到从库。

Redis缓存

深入理解Redis的多路复用模式、单线程和多线程模型、应用场景、简单字符串、链表、字典、跳跃表、压缩列表、持久化、过期策略、内存淘汰策略 、Redis与MySQL的数据一致性、Redis分布式锁、热点数据缓存、哨兵模式、集群模式、多级缓存架构、并发竞争、主从架构等。 有Redis调优经验,如绑核、大key优化、数据集中过期优化、碎片整理、内存大页优化、持久化优化、丢包/中断/CPU亲和性优化、操作系统Swap与主从同步优化、高可用主从同步和哨兵机制、多级缓存、冷热分离、缓存雪崩、穿透、击穿、热点缓存重构、缓存失效等。

Redis五大数据类型的应用场景

各数据类型应用场景
  • 工作中有很多场景经常用到redis, 比如在使用String类型的时候,字符串的长度不能超过512M,可以set存储单个值,也可以把对象转成json字符串存储;还有我们经常说到的分布式锁,就是通过setnx实现的,返回结果是1就说明获取锁成功,返回0就是获取锁失败,这个值已经被设置过。又或者是网站访问次数,需要有一个计数器统计访问次数,就可以通过incr实现。

  • 除了字符串类型,还有hash类型,它比string类型操作消耗内存和cpu更小,更节约空间。像我之前做过的电商项目里面,购物车实现场景可以通过hset添加商品,hlen获取商品总数,hdel删除商品,hgetall获取购物车所有商品。另外如果缓存对象的话,修改多个字段就不需要像String类型那样,取出值进行类型转换,然后设值进行类型转换,把它转成字符串缓存进行了。

  • 还有列表list这种类型,是简单的字符串列表,按照插入顺序排序,可以添加一个元素到列表的头部或者尾部,它的底层实际上是个链表结构。这种类型更多的是用在文章发布上面,类似微博消息和微信公众号文章,在我之前的项目里面也有用到,比如说我关注了二个媒体,这二个媒体先后发了新闻,我就可以看到先发新闻那家媒体的文章,它可以通过lpush+rpop队列这种数据结构实现先进先出,当然也可以通过lpush+lpop实现栈这种数据结构来到达先进后出的功能。

  • 然后就是集合set,底层是字典实现的,查找元素特别快,另外set 数据类型不允许重复,利用这两个特性我们可以进行全局去重,比如在用户注册模块,判断用户名是否注册。可以通过sadd、smembers等命令实现微信抽奖小程序,微信微博点赞,收藏,标签功能。还可以利用交集、并集、差集的特性实现微博微信的关注模型,交集和并集很好理解,差集可以解释一下,就是用第一个集合减去其他集合的并集,剩下的元素,就是差集。举个微博关注模型的例子,我关注了张三和李四,张三关注了李四和王五,李四关注了我和王五。
    我进入了张三的主页

    查看共同关注的人 (李四),取出我关注的人和张三关注的人,二个集合取交集得出结果是李四,就是通过SINTER交集实现的。

    查看我可能认识的人 (王五),取出我关注的人和张三关注的人,二个集合取并集得出结果是(张三,李四,王五),拿我关注的人(张三,李四)减去并集里的元素,剩下的王五就是我可能认识的人,可以通过并集和差集实现。

    查看我关注的人也关注了他(王五),取出我关注的人他们关注的人,(李四,王五)(我,王五)的交集,就是王五。

  • 最后就是有序集合zset,有序的集合,可以做范围查找,比如说排行榜,展示当日排行前十。

Redis五大数据类型实现原理

对于五大数据类型(String,list,Hash,Set,Zset)实现原理,Redis在底层用到了多种数据结构,通过数据结构来实现键值对,将数据结构创建了一个对象redisObject,根据对象的类型type,为对象设置多种不同的数据结构,对象可以执行特定的命令。

本章主要涉及到的知识点有:

  • redisObject的属性
  • 五大数据类型编码

注意:本章内容每一小节可单独学习,无论先后。

redisObject属性

学完本章中,读者需要回答:
1.Redis底层数据结构如何实现?
2.Redis是如何回收内存?

Redis的一个键值对,有两个对象,一个是键对象,一个是值对象,键总是一个字符串对象,而值可以是字符串、列表、集合等对象,Redis中的值对象都是由 redisObject 结构来表示:

java 复制代码
typedef struct redisObject{
     //表示类型:string,list,hash,set,zset
     unsigned type:4;
     //编码:比如字符串的编码有int编码,embstr编码,raw编码
     unsigned encoding:4;
     //指向底层数据结构的指针,prt是个指针变量,存放地址,指向数据存储的位置
     void *ptr;
     //引用计数,类似java里的引用计数
     int refcount;
     //记录最后一次被程序访问的时间
     unsigned lru:22;
}robj
type属性

redisObject 对象的type属性记录了对象的类型(string,list,hash,set,zset),可以通过type key命令来判断对象类型,从而区分redis中key-value的类型

java 复制代码
127.0.0.1:6379> set testString testValue
OK
127.0.0.1:6379> lpush testList testValue1 testValue2 testValue3
(integer) 3
127.0.0.1:6379> hmset testhash 1:testvalue 2:testvalue2
OK
127.0.0.1:6379> sadd testset testvalue
(integer) 1
127.0.0.1:6379> zadd testzset 1 testvalue
(integer) 1
127.0.0.1:6379> type testString
string
127.0.0.1:6379> type testList
list
127.0.0.1:6379> type testhash
hash
127.0.0.1:6379> type testset
set
127.0.0.1:6379> type testzset
zset
prt和encoding属性

redisObject 对象的 prt 指针,存放数据的地址,指向对象底层的数据结构,通过它可以找到数据的位置。

refcount 属性

由于C语言跟贴近操作系统,直接跟操作系统交互,命令执行响应比较快,所以Redis选择C语言进行编写可以提高性能,但是C 语言不具备自动回收内存功能,于是乎Redis自己构建了一个内存回收机制。

创建一个新对象,redisObject 对象中的refcount属性就会加1,对象被一个新程序使用,调用incrRefCount函数进行加 1,如果有对象不再被应用程序使用了,那么它就会调用decrRefCount函数进行减 1,当对象的引用计数值为 0 的时候,那么这个对象所占用的内存就会被释放。

从这里可以看出来,这其实就是Java虚拟机中引用计数的内存回收机制,在Java中这种回收机制不被使用,因为它不能解决循环引用的问题。
循环引用举例:A引用B,B引用C,C引用A。

Redis通过在配置文件中修改相关的配置,来达到解决循环引用的问题,在Redis的配置文件里,Windows的配置文件是redis.windows.conf,Linux系统的配置文件是redis.conf。

在配置文件中有一个配置:maxmemory-policy,当内存使用达到最大值时,redis使用的清楚策略,默认配置是noeviction

1)volatile-lru 删除已有的过期时间的key

2)allkeys-lru 删除所有的key

3)volatile-random 已有过期时间的key 随机删除

4)allkeys-random 随机删除key

5)volatile-ttl 删除即将过期的key

6)noeviction 不删除任何key,只是返回一个写错误,这个是默认选项 对于整数值的字符串对象(例如:1,2,3这种的)可实现内存共享。

问题:什么是内存共享?
定义 :键不同,值相同。
举例 :输入命令set key1 1024,键为 key1,值为1024的字符串对象,接着输入命令 set key2 1024 ,键为 key2,值为1024 的字符串对象。这个时候,有二个不同的键,一个相同的值。
实现原理 :键的值,指针指向一个有值的对象,被共享的值对象引用refcount 加 1。
局限性:判断两个对象是否相等需要消耗运算的额外的时间。整数值,判断操作复杂度低;普通字符串,判断复杂度相比较而已是高的;哈希、列表、集合和有序集合,判断的复杂度更高,所以内存共享只适用于整数值的字符串。

lru 属性

Lru属性是redisObject 记录对象最后一次被命令程序访问的时间,用来辅助lru算法删除过期内存的。

在Redis 配置文件中有三个配置,最大内存配置 maxmemory,触发数据淘汰后的淘汰策略 maxmemory_policy,随机采样的精度maxmemory_samples。

当有条件符合配置文件中三个配置的时候,继续往Redis中加key时,会触发执行 lru 策略,进行内存清除。最近最少使用,lru算法根据数据的历史访问记录进行数据淘汰。

Lru策略的运行原理是数据插入到链表头部,当缓存数据被访问之后,数据会移到链表头,链表满的时候,链表尾部的数据会被丢弃。

redis配置中的淘汰策略(maxmemory_policy)对应的值:

  • Noeviction:缓存里的数据超过maxmemory值,这个时候如果客户端正在执行命令,会让内存分配,给客户端返回错误响应
  • allkeys-lru: 所有的key都用LRU进行淘汰。
  • volatile-lru: LRU策略淘汰已经设置过过期时间的键。
  • allkeys-random:随机淘汰使用的。
  • key volatile-random:随机淘汰已设置过过期时间的key
  • volatile-ttl:只回收设置了过期时间的key

从redis缓存中淘汰数据,我们的需求是淘汰一些不可能被使用的数据,保留有些以后可能会频繁访问的数据,频繁访问的数据,将来被访问的可能性大很多,所以redis它记录每个数据的最后一次访问时间(lru记录的时间),通过当前时间减去键值对象lru记录的时间,最后可以计算出最少空闲时间,最少空闲时间的数据是最有可能被访问到,这就是LRU淘汰策略的设计思想,是不是很棒。
举例说明:

A数据每10s访问一次,B数据每5s访问一次,C数据每50s访问一次,|代表计算空闲时间的截止点。

预测被访问的概率是B > A > C。

过期key的删除策略有两种:
惰性删除 :每次获取键时,都检查键是否过期,过期的话,就删除该键;未过期,就返回该键。
定期删除:每隔一段时间,进行一次检查,删除里面的过期键。

encoding属性

数据结构由 encoding 属性,也就是编码,由它来决定,可以通过object encoding key命令查看一个值对象的编码。

java 复制代码
127.0.0.1:6379> object encoding testString
"embstr"
127.0.0.1:6379> object encoding testList
"quicklist"
127.0.0.1:6379> object encoding testhash
"ziplist"
127.0.0.1:6379> object encoding testset
"hashtable"
127.0.0.1:6379> object encoding testzset
"ziplist"
String类型编码

我们最常使用的redis的一个数据类型就是String类型,实现单值缓存,分布式锁,计数器,分布式系统全局序列号等等功能。

它的底层编码分为三种,int,raw或者embstr。

int编码 :存储整数值(例如:1,2,3),当 int 编码保存的值不再是整数值,又或者值的大小超过了long的范围,会自动转化成raw。例如:(1,2,3)->(a,b,c)
embstr编码 :存储短字符串。

它只分配一次内存空间,redisObject和sds是连续的内存,查询效率会快很多,也正是因为redisObject和sds是连续在一起,伴随了一些缺点:当字符串增加的时候,它长度会增加,这个时候又需要重新分配内存,导致的结果就是整个redisObject和sds都需要重新分配空间,这样是会影响性能的,所以redis用embstr实现一次分配而后,只允许读,如果修改数据,那么它就会转成raw编码,不再用embstr编码了。
raw编码 :用来存储长字符串。

它可以分配两次内存空间,一个是redisObject,一个是sds,二个内存空间不是连续的内存空间。和embstr编码相比,它创建的时候会多分配一次空间,删除时多释放一次空间。
版本区别:

embstr编码版本之间的区别:在redis3.2版本之前,用来存储39字节以内的数据,在这之后用来存储44字节以内的数据。

raw编码版本之间的区别:和embstr相反,redis3.2版本之前,可用来存储超过39字节的数据,3.2版本之后,它可以存储超过44字节的数据。
问题一:为什么是39字节?

从上面可以得知,embstr是一块连续的内存区域,由redisObject和sdshdr组成。

embstr最多占64字节场景:

redisObject占16个字节

java 复制代码
struct RedisObject {
    int4 type; // 4bits,不同的redis对象会有不同的数据类型(string、list、hash等),type记录类型,会用到4bits。
    int4 encoding; // 4bits,存储编码形式,用4bits。
    int24 lru; // 24bits,用24bits记录对象的LRU信息
    int32 refcount; // 4bytes = 32bits,引用计数器,用到32bits
    void *ptr; // 8bytes,64-bit system,指针指向对象的具体内容,需要64bits
}

计算: 4 + 4 + 24 + 32 + 64 = 128bits = 16bytes

sdshdr占48字节

java 复制代码
struct sdshdr {
    unsigned int len;//4个字节
    unsigned int free;//4个字节
    char buf[];//假设buf里面是39个字节
};

if (ptr) {
        memcpy(sh->buf,ptr,len);
        sh->buf[len] = '\0';//一个字节

sdshdr的大小为8+39+1=48

那么一个embstr最多占64字节:16+48(4+4+1+39)=64

从2.4版本开始,redis用jemalloc内存分配器,比glibc的malloc要好一些,省内存,jemalloc会分配8,16,32,64等类型字节的内存。

embstr最小为33字节场景:

从上面我们可以得知redisObject占16个字节,现在buf中取8字节。

java 复制代码
struct sdshdr {
    unsigned int len;//4个字节
    unsigned int free;//4个字节
    char buf[];//假设buf里面是8个字节
};

if (ptr) {
        memcpy(sh->buf,ptr,len);
        sh->buf[len] = '\0';//一个字节

sdshdr的大小为4+4+8+1=17

计算得出:16+17(4+4+1+8)=33

8,16,32都比33字节小,所以最小分配64字节。

通过对比:

16+17(4+4+1+8)=33

16+48(4+4+1+39)=64

当字符数大于8时,会分配64字节。当字符数小于39时,会分配64字节。这个默认39就是这样来的。

问题二:为什么分界值由39字节会变成44字节?

被暴打的回答是:REDIS_ENCODING_EMBSTR_SIZE_LIMIT值被换成了44了。

java 复制代码
##define REDIS_ENCODING_EMBSTR_SIZE_LIMIT 39
##define REDIS_ENCODING_EMBSTR_SIZE_LIMIT 44

正经的回答是:

每个sds都有一个sdshdr,里面的len和free记录了这个sds的长度和空闲空间。

java 复制代码
struct sdshdr {
    unsigned int len;
    unsigned int free;

用的unsigned int可以表示很大的范围,短的sds空间被浪费了(unsigned int len和unsigned int free 8个字节)

commit之后,unsigned int 变成了uint8_t,uint16_t,uint32_t

java 复制代码
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* used */
    uint8_t alloc; /* excluding the header and null terminator */
    char flags; /* 2 lsb of type, and 6 msb of refcount */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; /* used */
    uint16_t alloc; /* excluding the header and null terminator */
    char flags; /* 2 lsb of type, and 6 msb of refcount */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len; /* used */
    uint32_t alloc; /* excluding the header and null terminator */
    char flags; /* 2 lsb of type, and 6 msb of refcount */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len; /* used */
    uint64_t alloc; /* excluding the header and null terminator */
    char flags; /* 2 lsb of type, and 6 msb of refcount */

除此之外还将原来的sdshdr改成了sdshdr16,sdshdr32,sdshdr64

java 复制代码
sizes = sdscatprintf(sizes,"sdshdr:%d", (int)sizeof(struct sdshdr));
改成了
sizes = sdscatprintf(sizes,"sdshdr8:%d", (int)sizeof(struct sdshdr8));
sizes = sdscatprintf(sizes,"sdshdr16:%d", (int)sizeof(struct sdshdr16));
sizes = sdscatprintf(sizes,"sdshdr32:%d", (int)sizeof(struct sdshdr32));
sizes = sdscatprintf(sizes,"sdshdr64:%d", (int)sizeof(struct sdshdr64));

unsigned int占四个字节

uint8_t 占1个字节

Char 占一个字节

我们通过计算可以得出为什么优化之后会多出5个字节了,短字符串的embstr用最小的sdshdr8。

sdsdr8 = uint8_t _ 2 + char = 1_2+1 = 3

sdshdr = unsigned int _ 2 = 4 _ 2 = 8

这么一算是不是少了五个字节了,所以3.2版本更新之后,由于优化小sds的内存使用,使得原本39个字节可以多使用5个字节,这就变成了44字节了。
问题三:Redis字符串最大长度是多少?

512M,查看源码可知。

java 复制代码
static int checkStringLength(redisClient *c, long long size) {
    if (size > 512*1024*1024) {
        addReplyError(c,"string exceeds maximum allowed size (512MB)");
        return REDIS_ERR;
    }
    return REDIS_OK;
}
List集合对象编码

List类型可以实现栈,队列,阻塞队列等数据结构,底层是个链表结构,它的底层编码分二种:ziplist(压缩列表) 和 linkedlist(双端链表)。

超过配置的数量或者最大的元素超过临界值时,符合配置的值,触发机制会选择不同的编码。

列表保存元素个数小于512个,每个元素长度小于64字节的时候触发机制会使用ziplist(压缩列表)编码,否则使用linkedlist(双端链表)。

在redis.conf(linux系统)或者redis.windows.conf(windows系统)对应的配置:

java 复制代码
list-max-ziplist-entries 512
list-max-ziplist-value 64

通过修改配置这二个配置,设置触发条件选择编码。比如我修改列表保存元素个数小于1024个并且每个元素长度小于128字节时使用ziplist(压缩列表)编码,否则使用linkedlist(双端链表)。修改配置如下:

java 复制代码
list-max-ziplist-entries 1024
list-max-ziplist-value 128

list列表的编码,3.2之前最开始的时候是用ziplist压缩列表,当列表保存元素个数超过512个,每个元素长度超过64字节就会切换编码,改用linkedlist双端链表,ziplist会有级联更新的情况,时间复杂度高,除此之外链表需要维护额外的前后节点,占用内存,所以元素个数到达一定数量就不能再用ziplist了。

新版本的Redis对列表的数据结构进行了改造,使用quicklist代替了原有的数据几个,quicklist是ziplist和linkedlist的混合体,它让每段ziplist连接起来,对ziplist进行LZF算法压缩,默认每个ziplist长度8KB。

ziplist压缩列表是由一些连续的内存块组成的,有顺序的存储结构,是一种专门节约内存而开发的顺序型数据结构。在物理内存固定不变的情况下,随着内存慢慢增加会出现内存不够用的情况,这种情况可以通过调整配置文件中的二个参数,让list类型的对象尽可能的用压缩列表编码,从而达到节约内存的效果,但是也要均衡一下编码和解码对性能的影响,如果有一个几十万的列表长度进行列表压缩的话,在查询和插入的时候,进行编解码会对性能造成特别大的损耗。

如果有不可避免的长列表的存储的话,需要在代码层面配合降低redis存储的内存,在存储redis的key的时候,在保证唯一性和可读性的时候,尽量简化redis的key,可以比较直接的节约redis空间的一个作用,还有就是对长列表进行拆分,比如说有一万条数据,压缩列表的保存元素的个数配置的是2048,我们就可以将一万条数据拆分成五个列表进行缓存,将它的元素个数控制在压缩列表配置的2048以内,当然这么做需要对列表的key进行一定的控制,当要进行查询的时候,可以精准的查询到key存储的数据。

这是对元素个数的一个控制,元素的长度也类似,将每个大的元素,拆分成小的元素,保证不超过配置文件里面每个元素大小,符合压缩列表的条件就可以了,核心目标就是保证这二个参数在压缩列表以内,不让它转成双端列表,并且在编解码的过程中,性能也能得到均衡,达到节约内存的目的。

除了上面的优化可以进行内存优化以外,还可以看我们缓存的数据,是不是可以打包成二进制位和字节进行存储,比如用户的位置信息,以上海市黄浦区举例说明,可以把上海市,黄浦区弄到我们的数组或者list里面,然后只需要存储上海市的一个索引0和黄浦区的一个索引1,直接将01存储到redis里面即可,当我们从缓存拿出这个01信息去数组或者list里面取到真正的一个消息。

Hash对象编码

Hash类型比string类型消耗内存和cpu更小。Hash的编码有二种 ziplist编码 或者 hashtable。

超过指定的值,最大的元素超过临界值时,符合配置的值,触发机制选择不同的编码。列表保存元素个数小于512个,每个元素长度小于64字节的时候,使用ziplist(压缩列表)编码,否则使用hashtable 。

配置文件中可以通过修改set-max-intset-entries 1024达到改变列表保存元素个数小于1024个,原理类似。

hashtable 编码是字典作为底层实现,字典的键是字符串对象,值则全部设置为 null。在上面的字典也有详细介绍。

Set集合对象编码

Set类型可以实现抽奖小程序,点赞,收藏,加标签,关注模型等功能。Set的编码有二种intset 或者 hashtable。

超过指定的值,最大的元素超过临界值时,符合配置条件,触发机制选择不同的编码。集合对象中所有元素都是整数,对象元素数量不超过512时,使用intset编码,否则使用hashtable。原理大致和上面的类型相同。

列表保存元素个数的配置也是通过set-max-intset-entries进行修改的。

intset 编码用整数集合作为底层实现,hashtable编码可以类比HashMap的实现,HashTable类中存储的实际数据是Entry对象,数据结构与HashMap是相同的。

Zset有序集合对象编码

Zset适合做排序以及范围查询等功能,比如实现实现排行榜等。有序集合的编码有二种 ziplist 或者 skiplist。

保存的元素数量小于128,存储的所有元素长度小于64字节的时候,使用ziplist编码,否则用skiplist编码。修改配置如下:

java 复制代码
zset-max-ziplist-entries 128
zset-max-ziplist-value 64

ziplist 编码底层是用压缩列表实现的,集合元素是两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员,第二个节点保存元素的分值。 压缩列表的集合元素按照设置的分值从小到大的顺序进行排列,小的放置在靠近表头的位置,大的放置在靠近表尾的位置。

skiplist 编码的有序集合对象使用 zet 结构作为底层实现,一个 zset 结构同时包含一个字典和一个跳跃表。

当不满足这二个条件的时候,skiplist编码,skiplist编码的有序集合对象使用zet 结构作为底层实现,一个 zset 结构同时包含一个字典和一个跳跃表,字典的键保存元素的值,字典的值则保存元素的分值;跳跃表由zskiplistNode和skiplist两个结构,跳跃表skiplist中的object属性保存元素的成员,score 属性保存元素的分值。这两种数据结构会通过指针来共享相同元素的成员和分值,所以不会产生重复成员和分值,造成内存的浪费。

问题:为什么需要二种数据结构?

假如我们单独使用字典,虽然能直接通过字典的值查找成员的分值,但是因为字典是以无序的方式来保存集合元素,所以每次进行范围操作的时候都要进行排序;

假如我们单独使用跳跃表来实现,虽然能执行范围操作,但是查找操作就会变慢,所以Redis使用了两种数据结构来共同实现有序集合。

除了这二个属性之外,还有层属性,跳跃表基于有序链表的,在链表上建索引,每两个结点提取一个结点到上一级,我们把抽出来的那一级叫作索引,每个跳跃表节点的层高都是1至32之间的随机数。

比如有一个有序链表,节点值依次是1->3->4->5。取出所有值为奇数的节点作为索引,这个时候要插入一个值是2的新节点,就不需要将节点一个个比较,只要比较1,3,5,确定了值在1和3之间,就可以快速插入,加一层索引之后,查找一个结点需要遍历的结点个数减少了,虽然增加了50%的额外空间,但是查找效率提高了。

当大量的新节点通过逐层比较,最终插入到原链表之后,上层的索引节点会慢慢的不够用,由于跳跃表的删除和添加节点是无法预测的,不能保证索引绝对分步均匀,所以通过抛硬币法:随机决定新节点是否选拔,每向上提拔一层的几率是50%,让大体趋于均匀。

🍊 Redis持久化

面试题:Redis 的持久化有哪几种方式?不同的持久化机制都有什么优缺点?持久化机制具体底层是如何实现的?save与bgsave?

持久化主要是做灾难恢复、数据恢复,高可用。比如你 redis 整个挂了,然后 redis 就不可用了,我们要做的事情就是让 redis 变得可用,尽快变得可用。 重启 redis,尽快让它堆外提供服务,如果没做数据备份,这时候 redis 启动了,也不可用啊,数据都没了。把 redis 持久化做好, 那么即使 redis 故障了,也可以通过备份数据,快速恢复,一旦恢复立即对外提供服务。

redis持久化有三种方式:RDB,AOF,(RDB和AOF)混合持久化

默认情况下, Redis 将内存数据库快照保存在名字为 dump.rdb 的二进制文件中,也就是RDB快照。

RDB 持久化机制,是对 redis 中的数据执行周期性的持久化。

AOF 持久化机制,是对每条写入命令作为日志,重启的时候,可以通过回放日志中的写入指令来重新构建整个数据集。

不同的持久化机制都有什么优缺点?

🎉 RDB持久化

RDB会生成多个数据文件,每个数据文件都代表了某一个时刻中 redis 的数据。 redis 主进程只需要 fork一个子进程,让子进程执行磁盘 IO 操作来进行 RDB持久化,对外提供的读写服务,影响非常小。但是如果数据文件特别大,可能会导致对客户端提供的服务暂停数秒。 RDB 数据文件来重启和恢复 redis 进程更快 RDB会丢失某一时间段的数据,一般来说,RDB 数据快照文件,都是每隔 5分钟,或者更长时间生成一次,这个时候就得接受一旦 redis 进程宕机,那么会丢失最近 5 分钟的数据。

🎉 AOF持久化

AOF 可以更好的保护数据不丢失,一般 AOF 每隔 1 秒,通过一个后台线程执行一次fsync操作,最多丢失 1 秒钟的数据。 AOF日志文件以 append-only 模式写入,所以没有任何磁盘寻址的开销,写入性能很高,而且文件不容易破损。 AOF 日志文件即使过大的时候,可以进行后台重写操作,也不会影响客户端的读写。在重写的时候,会进行压缩,创建出一份最小恢复数据的日志出来。在创建新日志文件的时候,老的日志文件还是照常写入。新日志文件创建完成以后,再去读的时候,交换新老日志文件就可以了。某人不小心用 flushall 命令清空了所有数据,只要这个时候后台重写命令还没有发生,那么就可以立即拷贝 AOF 文件,将最后一 flushall 命令给删了,然后再将该 AOF 文件放回去,就可以通过恢复机制,自动恢复所有数据。 AOF 日志文件通常比 RDB数据快照文件更大。 支持的写 QPS 会比 RDB 支持的写 QPS 低,因为 AOF 一般会配置成每秒 fsync一次日志文件,当然,每秒一次 fsync,性能也还是很高的。

🎉 混合持久化

仅仅使用 RDB,会导致丢失很多数据 仅仅使用 AOF,速度慢,支持的QPS低,性能不高 开启开启两种持久化方式,用 AOF 来保证数据不丢失,作为数据恢复的第一选择; 在 AOF 文件都丢失或损坏不可用的时候,还可以使用 RDB 来进行快速的数据恢复。

🎉 持久化底层实现原理

持久化机制具体底层是如何实现的?

📝 RDB持久化底层实现原理

RDB持久化可以通过配置与手动执行命令生成RDB文件。 可以对 Redis 进行设置, 让它在" N 秒内数据集至少有 M个改动"这一条件被满足时, 自动保存一次数据集。比如说设置让 Redis 在满足" 60 秒内有至少有 1000 个键被改动",自动保存一次数据集。通过 save 60 1000 命令生成RDB快照,关闭RDB只需要将所有的save保存策略注释掉即可。手动执行命令生成RDB快照,进入redis客户端执行命令save或bgsave可以生成dump.rdb文件,每次命令执行都会将所有redis内存快照到一个新的rdb文件里,并覆盖原有rdb快照文件。

📝 AOF持久化底层实现原理

AOF持久化可以通过配置与手动执行命令生成RDB文件。 通过配置# appendonly yes 开启AOF持久化, 每当 Redis 执行一个改变数据集的命令时, 这个命令就会被追加到 AOF 文件的末尾,当 Redis 重新启动时, 程序就可以通过重新执行 AOF 文件中的命令来达到重建数据集的目的,配置 Redis 多久才将数据 fsync 到磁盘一次,默认的措施为每秒 fsync 一次。AOF文件里可能有太多没用指令,所以AOF会定期根据内存的最新数据重新生成aof文件,可以通过配置文件达到64M才会自动重写,也可以配置aof文件自上一次重写后文件大小增长了100%则再次触发重写 手动执行命令bgrewriteaof重写AOF,AOF重写redis会fork出一个子进程去做(与bgsave命令类似),不会对redis正常命令处理有太多影响。

📝 混合持久化底层实现原理

通过配置# aof-use-rdb-preamble yes 开启混合持久化,开启了混合持久化,AOF在重写时,不再是单纯将内存数据转换为RESP命令写入AOF文件,而是将重写这一刻之前的内存做RDB快照处理,并且将RDB快照内容和增量的AOF修改内存数据的命令存在一起,都写入新的AOF文件,新的文件一开始不叫appendonly.aof,等到重写完新的AOF文件才会进行改名,覆盖原有的AOF文件,完成新旧两个AOF文件的替换。于是在 Redis 重启的时候,可以先加载 RDB 的内容,然后再重放增量 AOF 日志就可以完全替代之前的 AOF 全量文件重放,因此重启效率大幅得到提升。

📝 save与bgsave

bgsave 子进程是由主线程 fork 生成的,可以共享主线程的所有内存数据。bgsave 子进程运行后,开始读取主线程的内存数据,并把它们写入 RDB 文件。此时,如果主线程对这些数据也都是读操作,那么,主线程和 bgsave 子进程相互不影响。但是,如果主线程要修改一块数据,那么,这块数据就会被复制一份,生成该数据的副本。然后,bgsave 子进程会把这个副本数据写入 RDB 文件,而在这个过程中,主线程仍然可以直接修改原来的数据。 save 它是同步阻塞的,会阻塞客户端命令和redis其它命令,和bgsave相比不会消耗额外内存。

🍊 缓存雪崩

一个系统,高峰期请求为5000次/秒,4000次走了缓存,只有1000次落到了数据库上,数据库每秒1000的并发是一个正常的指标,完全可以正常工作,但如果缓存宕机了,或者缓存设置了相同的过期时间,导致缓存在同一时刻同时失效,每秒5000次的请求会全部落到数据库上,数据库立马就死掉了,因为数据库一秒最多抗2000个请求,如果DBA重启数据库,立马又会被新的请求打死了,这就是缓存雪崩。

解决方案

事前:redis高可用,主从+哨兵,redis cluster,避免全盘崩溃 事中:本地ehcache缓存 + hystrix限流&降级,避免MySQL被打死

事后:redis持久化RDB+AOF,快速恢复缓存数据 缓存的失效时间设置为随机值,避免同时失效

🍊 缓存穿透

客户端每秒发送5000个请求,其中4000个为黑客的恶意攻击,即在数据库中也查不到。举个例子,用户id为正数,黑客构造的用户id为负数,如果黑客每秒一直发送这4000个请求,缓存就不起作用,数据库也很快被打死。

解决方案

对请求参数进行校验,不合理直接返回 查询不到的数据也放到缓存,value为空,如 set -999 "" 使用布隆过滤器,快速判断key是否在数据库中存在,不存在直接返回

🍊 缓存击穿

设置了过期时间的key,承载着高并发,是一种热点数据。从这个key过期到重新从MySQL加载数据放到缓存的一段时间,大量的请求有可能把数据库打死。缓存雪崩是指大量缓存失效,缓存击穿是指热点数据的缓存失效。

解决方案

设置key永远不过期,或者快过期时,通过另一个异步线程重新设置key 当从缓存拿到的数据为null,重新从数据库加载数据的过程上分布式锁。

🍊 布隆过滤器

需求

①、原本有10亿个号码,现在又来了10万个号码,要快速准确判断这10万个号码是否在10亿个号码库中? 解决办法一:将10亿个号码存入数据库中,进行数据库查询,准确性有了,但是速度会比较慢。 解决办法二:将10亿号码放入内存中,比如Redis缓存中,这里我们算一下占用内存大小:10亿*8字节=8GB,通过内存查询,准确性和速度都有了,但是大约8gb的内存空间,挺浪费内存空间的。

②、接触过爬虫的,应该有这么一个需求,需要爬虫的网站千千万万,对于一个新的网站url,我们如何判断这个url我们是否已经爬过了? 解决办法还是上面的两种,很显然,都不太好。

③、同理还有垃圾邮箱的过滤 大数据量集合,如何准确快速的判断某个数据是否在大数据量集合中,并且不占用内存。

🎉 布隆过滤器定义

一种数据结构,是由一串很长的二进制向量组成,可以将其看成一个二进制数组。既然是二进制,那么里面存放的不是0,就是1,但是初始默认值都是0。将布隆过滤器看成一个容器,那么如何向布隆过滤器中添加一个数据呢?数组是从0开始计数的,当要向布隆过滤器中添加一个元素key时,我们通过多个hash函数,算出一个值,然后将这个值所在的方格置为1。

🎉 布隆过滤器判断数据是否存在?

将这个新的数据通过自定义的几个哈希函数,分别算出各个值,然后看其对应的地方是否都是1,如果存在一个不是1的情况,那么我们可以说,该新数据一定不存在于这个布隆过滤器中。多个不同的数据通过hash函数算出来的结果是会有重复的,所以会存在某个位置是别的数据通过hash函数置为的1。布隆过滤器可以判断某个数据一定不存在,但是无法判断一定存在。

🎉 布隆过滤器优缺点

优点:二进制组成的数组,占用内存极少,并且插入和查询速度都足够快。

缺点:随着数据的增加,误判率会增加,无法判断数据一定存在,无法删除数据。

🎉 布隆过滤器的实现

  1. guava 工具包提供了布隆过滤器的实现。
  2. Redis 实现布隆过滤器的底层就是通过 bitmap数据结构实现的,计算机以二进制位作为底层存储的基础单位,一个字节等于8位,可以通过修改二进制某个位置上的0或者1达到修改值的目的。比如:将big改为cig,"b"的二进制表示为0110 0010,我们将第7位(从0开始)设置为1,那0110 0011表示的就是字符"c",所以最后的字符 "big"变成了"cig"。

🍊 Redis过期策略

Redis采用的过期策略

惰性删除+定期删除

🎉 惰性删除流程

在进行get或setnx等操作时,先检查key是否过期,若过期,删除key,然后执行相应操作;若没过期,直接执行相应操作

🎉 定期删除流程

对指定个数个库的每一个库随机删除小于等于指定个数个过期key,遍历每个数据库(就是redis.conf中配置的"database"数量,默认为16),检查当前库中的指定个数个key(默认是每个库检查20个key,注意相当于该循环执行20次,循环体时下边的描述),如果当前库中没有一个key设置了过期时间,直接执行下一个库的遍历,随机获取一个设置了过期时间的key,检查该key是否过期,如果过期,删除key,判断定期删除操作是否已经达到指定时长,若已经达到,直接退出定期删除。

问题 :定期删除漏掉了很多过期 key,然后你也没及时去查,也就没走惰性删除,此时会怎么样?如果大量过期 key 堆积在内存里,导致 Redis 内存块耗尽了,怎么解决呢?走内存淘汰机制。

🎉 内存淘汰机制

Redis 内存淘汰机制有以下几个:

noeviction: 当内存不足以容纳新写入数据时,新写入操作会报错,这个一般没人用吧,实在是太恶心了。

allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key(这个是最常用的)。

allkeys-random:当内存不足以容纳新写入数据时,在键空间中,随机移除某个 key,这个一般没人用吧,为啥要随机,肯定是把最近最少使用的 key 给干掉啊。

volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的 key(这个一般不太合适)。

volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个 key。

volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的 key 优先移除。

默认就是如果满的话就拒绝抛异常,正常一般用LFU和LRU二种。LFU是基于梯形数组,每个数组上面就挂了一个Counter,Counter是用来统计它的服务次数的,通过访问次数来进行升级,LFU的LRU字段里面高16位存储一个分钟数级别的时间戳,低8位存储的是一个Counter访问计数。和LRU相比,LFU避免了LRU基于最近一段时间的访问没有访问数据,突然访问变成热点数据,导致内存淘汰,没有真正意义上达到冷数据的淘汰。

🎉 RDB对过期key的处理

过期key对RDB没有任何影响,从内存数据库持久化数据到RDB文件:持久化key之前,会检查是否过期,过期的key不进入RDB文件 从RDB文件恢复数据到内存数据库:数据载入数据库之前,会对key先进行过期检查,如果过期,不导入数据库(主库情况)

🎉 AOF对过期key的处理

过期key对AOF没有任何影响 从内存数据库持久化数据到AOF文件:当key过期后,还没有被删除,此时进行执行持久化操作(该key是不会进入aof文件的,因为没有发生修改命令)当key过期后,在发生删除操作时,程序会向aof文件追加一条del命令(在将来的以aof文件恢复数据的时候该过期的键就会被删掉) AOF重写:重写时,会先判断key是否过期,已过期的key不会重写到aof文件。

🍊 Redis与数据库的数据一致性

关于redis与数据库的数据一致性,业界使用最多的是数据同步问题(双删策略)

🎉 双删策略

先更新数据库,再更新缓存;

同时有请求A和请求B进行更新操作,那么会出现:

  1. 线程A更新了数据库;
  2. 线程B更新了数据库;
  3. 线程B更新了缓存;
  4. 线程A更新了缓存;

缺点

这就出现请求A更新缓存应该比请求B更新缓存早才对,但是因为网络等原因,B却比A更早更新了缓存。这就导致了脏数据,因此不考虑!

如果你是一个写数据库场景比较多,而读数据场景比较少的业务需求,采用这种方案就会导致,数据压根还没读到,缓存就被频繁的更新,浪费性能。

如果你写入数据库的值,并不是直接写入缓存的,而是要经过一系列复杂的计算再写入缓存。那么,每次写入数据库后,都再次计算写入缓存的值,无疑是浪费性能的。显然,删除缓存更为适合。

先删除缓存,再更新数据库;

同时有一个请求A进行更新操作,另一个请求B进行查询操作。那么会出现如下情形:

(1)请求A进行写操作,删除缓存;

(2)请求B查询发现缓存不存在;

(3)请求B去数据库查询得到旧值;

(4)请求B将旧值写入缓存;

(5)请求A将新值写入数据库;

导致数据不一致的情形出现,如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。

🎉 延时双删策略

解决方案:延时双删策略

(1)先淘汰缓存;

(2)再写数据库(这两步和原来一样);

(3)休眠1秒,再次淘汰缓存;

这么做,可以将1秒内所造成的缓存脏数据,再次删除!这个一秒如何得出来的呢?评估自己的项目的读数据业务逻辑的耗时,在读数据业务逻辑的耗时基础上,加几百ms即可,确保读请求结束,写请求可以删除读请求造成的缓存脏数据。

MySQL的读写分离架构中

一个请求A进行更新操作,另一个请求B进行查询操作。

(1)请求A进行写操作,删除缓存;

(2)请求A将数据写入数据库了;

(3)请求B查询缓存发现,缓存没有值;

(4)请求B去从库查询,这时,还没有完成主从同步,因此查询到的是旧值;

(5)请求B将旧值写入缓存;

(6)数据库完成主从同步,从库变为新值; 导致数据不一致,解决方案使用双删延时策略。只是,睡眠时间修改为在主从同步的延时时间基础上,加几百ms。

采用这种同步淘汰策略,吞吐量降低怎么办? ok,那就将第二次删除作为异步的。自己起一个线程,异步删除。这样,写的请求就不用沉睡一段时间后了,再返回。这么做,加大吞吐量。

🎉 异步延时删除策略

先更新数据库,再删除缓存; 一个请求A做查询操作,一个请求B做更新操作,那么会有如下情形产生:

(1)缓存刚好失效;

(2)请求A查询数据库,得一个旧值;

(3)请求B将新值写入数据库;

(4)请求B删除缓存;

(5)请求A将查到的旧值写入缓存;

问题:会发生脏数据,但是几率不大,因为步骤(3)的写数据库操作比步骤(2)的读数据库操作耗时更短,才有可能使得步骤(4)先于步骤(5)。可是,大家想想,数据库的读操作的速度远快于写操作的(不然做读写分离干嘛,做读写分离的意义就是因为读操作比较快,耗资源少),因此步骤(3)耗时比步骤(2)更短,这一情形很难出现。

如何解决脏数据呢?给缓存设有效时间是一种方案。其次,采用策略2(先删除缓存,再更新数据库)里给出的异步延时删除策略,保证读请求完成以后,再进行删除操作。

第二次删除,如果删除失败怎么办? 这是个非常好的问题,因为第二次删除失败,就会出现如下情形。还是有两个请求,一个请求A进行更新操作,另一个请求B进行查询操作,为了方便,假设是单库: (1)请求A进行写操作,删除缓存; (2)请求B查询发现缓存不存在; (3)请求B去数据库查询得到旧值; (4)请求B将旧值写入缓存; (5)请求A将新值写入数据库; (6)请求A试图去删除请求B写入对缓存值,结果失败了;ok,这也就是说。如果第二次删除缓存失败,会再次出现缓存和数据库不一致的问题。

解决方案一

(1)更新数据库数据;

(2)缓存因为种种问题删除失败;

(3)将需要删除的key发送至消息队列;

(4)自己消费消息,获得需要删除的key;

(5)继续重试删除操作,直到成功; 缺点:对业务线代码造成大量的侵入

解决方案二: 启动一个订阅程序去订阅数据库的binlog,获得需要操作的数据。在应用程序中,另起一段程序,获得这个订阅程序传来的信息,进行删除缓存操作。

(1)更新数据库数据;

(2)数据库会将操作信息写入binlog日志当中;

(3)订阅程序提取出所需要的数据以及key;

(4)另起一段非业务代码,获得该信息;

(5)尝试删除缓存操作,发现删除失败;

(6)将这些信息发送至消息队列;

(7)重新从消息队列中获得该数据,重试操作;

订阅binlog程序在mysql中有现成的中间件叫canal,可以完成订阅binlog日志的功能。重试机制,采用的是消息队列的方式。如果对一致性要求不是很高,直接在程序中另起一个线程,每隔一段时间去重试。

🍊 Redis分布式锁底层实现

🎉 如何实现

redis使用setnx作为分布式锁,在多线程环境下面,只有一个线程会拿到这把锁,拿到锁的线程执行业务代码,执行业务代码需要一点时间,所以这段时间拒绝了很多等待获取锁的请求,直到有锁的线程最后释放掉锁,其他线程才能获取锁,这个就是redis的分布式锁的使用。

🎉 使用redis锁会有很多异常情况,如何处理这些异常呢

📝 1.redis服务挂掉了,抛出异常了,锁不会被释放掉,新的请求无法进来,出现死锁问题

添加try finally处理

📝 2.服务器果宕机了,导致锁不能被释放的现象

设置超时时间

📝 3.锁的过期时间比业务执行时间短,会存在多个线程拥有同一把锁的现象

如果有一个线程执行需要15s,过期时间只有10s,当执行到10s时第二个线程进来拿到这把锁,会出现多个线程拿到同一把锁执行。

续期超时时间,当一个线程执行5s后对超时时间续期10s,续期设置可以借助redission工具,加锁成功,后台新开一个线程,每隔10秒检查是否还持有锁,如果持有则延长锁的时间,如果加锁失败一直循环(自旋)加锁。

📝 4.锁的过期时间比业务执行时间短,锁永久失效

如果有一个线程执行需要15s,过期时间只有10s,当执行到10s时第二个线程进来拿到这把锁,会出现多个线程拿到同一把锁执行,在第一个线程执行完时会释放掉第二个线程的锁,以此类推,导致锁的永久失效。

给每个线程都设置一个唯一标识,避免出现程序执行的时间超过设置的过期时间,导致其他线程删除了自己的锁,只允许自己删除自己线程的锁

🍊 Redis热点数据缓存

热点数据缓存

当前key是一个热点key(例如一个热门的娱乐新闻),并发量非常大重建缓存不能在短时间完成,可能是一个复杂计算,例如复杂的SQL、多次IO、多个依赖等在缓存失效的瞬间,有大量线程来重建缓存,造成后端负载加大,甚至可能会让应用崩溃。

🎉 互斥锁(mutex)

解决方案一:互斥锁(mutex)

只允许一个线程重建缓存,其他线程等待重建缓存的线程执行完,重新从缓存获取数据。

    1)从Redis获取数据,如果值不为空,则直接返回值;否则执行下面的2.1)和2.2)步骤    2.1)如果set(nx和ex)结果为true,说明此时没有其他线程重建缓存, 那么当前线程执行缓存构建逻辑    2.2)如果set(nx和ex)结果为false,说明此时已经有其他线程正在执 行构建缓存的工作,那么当前线程将休息指定时间(例如这里是50毫秒,取决于构建缓存的速度)后,重新执行函数,直到获取到数据。

优缺点 :如果构建缓存过程出现问题或者时间较长,可能会存在死锁和线程池阻塞的风险,但是这种方法能够较好地降低后端存储负载,并在一致性上做得比较好。

🎉 永远不过期

解决方案二:永远不过期

从缓存层面来看,确实没有设置过期时间,所以不会出现热点key过期 后产生的问题,也就是"物理"不过期。从功能层面来看,为每个value设置一个逻辑过期时间,当发现超过逻 辑过期时间后,会使用单独的线程去构建缓存。

优缺点 :由于没有设置真正的过期时间,实际上已经不存在热点key产生的一系列危害,但是会存在数据不一致的情况,同时代码复杂度会增大。
问题:怎么知道哪些数据是热点数据?因为本地缓存资源有限,不可能把所有的商品数据进行缓存,它只会缓存热点的数据。那怎么知道数据是热点数据呢?

利用redis4.x自身特性,LFU机制发现热点数据。实现很简单,只要把redis内存淘汰机制设置为allkeys-lfu或者volatile-lfu方式,再执行./redis-cli --hotkeys会返回访问频率高的key,并从高到底的排序,在设置key时,需要把商品id带上,这样就是知道是哪些商品了。

🍊 哨兵机制

哨兵是一个分布式系统,你可以在一个架构中运行多个哨兵进程,这些进程使用流言协议来接收关于主节点是否下线的信息,并使用投票协议来决定是否执行自动故障迁移,以及选择哪个备节点作为新的主节点。每个哨兵会向其它哨兵、主节点、备节点定时发送消息,以确认对方是否"活"着,如果发现对方在指定时间(可配置)内未回应,则暂时认为对方已挂。
若"哨兵群"中的多数哨兵,都报告某一主节点没响应,系统才认为该主节点"彻底死亡",通过算法,从剩下的备节点中,选一台提升为主节点,然后自动修改相关配置。可以通过修改sentinel.conf配置文件,配置主节点名称,IP,端口号,选举次数,主服务器的密码,心跳检测毫秒数,做多少个节点等。

🎉 Redis 哨兵主备切换的数据丢失问题

📝 异步复制导致的数据丢失

master->slave 的复制是异步的,所以可能有部分数据还没复制到 slave,master 就宕机了,此时这部分数据就丢失了。 脑裂导致的数据丢失:某个 master 所在机器突然脱离了正常的网络,跟其他 slave 机器不能连接,但是实际上 master还运行着。此时哨兵可能就会认为 master 宕机了,然后开启选举,将其他 slave 切换成了 master。这个时候,集群里就会有两个master ,也就是所谓的脑裂。 此时虽然某个 slave 被切换成了 master,但是可能 client 还没来得及切换到新的master,还继续向旧 master 写数据。因此旧 master 再次恢复的时候,会被作为一个 slave 挂到新的 master上去,自己的数据会清空,重新从新的 master 复制数据。而新的 master 并没有后来 client写入的数据,因此,这部分数据也就丢失了

解决方案:

进行配置:min-slaves-to-write 1 min-slaves-max-lag 10

通过配置至少有 1 个 slave,数据复制和同步的延迟不能超过 10 秒,超过了master 就不会再接收任何请求了。

减少异步复制数据的丢失

一旦 slave 复制数据和 ack 延时太长,就认为可能 master 宕机后损失的数据太多了,那么就拒绝写请求,这样可以把 master宕机时由于部分数据未同步到 slave 导致的数据丢失降低的可控范围内。 减少脑裂的数据丢失:如果一个 master 出现了脑裂,跟其他slave 丢了连接,如果不能继续给指定数量的slave 发送数据,而且 slave 超过10 秒没有给自己ack消息,那么就直接拒绝客户端的写请求。因此在脑裂场景下,最多就丢失10 秒的数据。

🍊 集群模式

数据量很少的情况下,比如你的缓存一般就几个 G,单机就足够了,可以使用 replication,一个 master 多个 slaves,要几个 slave 跟你要求的读吞吐量有关,然后自己搭建一个 sentinel 集群去保证 Redis 主从架构的高可用性。

海量数据+高并发+高可用的场景的情况下,使用Redis cluster ,自动将数据进行分片,每个 master 上放一部分数据,它支撑 N个 Redis master node,每个 master node 都可以挂载多个 slave node。 这样整个 Redis就可以横向扩容了,如果你要支撑更大数据量的缓存,那就横向扩容更多的 master 节点,每个 master节点就能存放更多的数据了。而且部分 master 不可用时,还是可以继续工作的。

在 Redis cluster 架构下,使用cluster bus 进行节点间通信,用来进行故障检测、配置更新、故障转移授权。cluster bus 用了一种二进制的协议, gossip 协议,用于节点间进行高效的数据交换,占用更少的网络带宽和处理时间。

🎉 集群协议

集群元数据的维护:集中式、Gossip 协议

📝 集中式

集中式是将集群元数据(节点信息、故障等等)几种存储在某个节点上。集中式元数据集中存储的一个典型代表,就是大数据领域的 storm。它是分布式的大数据实时计算引擎,是集中式的元数据存储的结构,底层基于zookeeper对所有元数据进行存储维护。集中式的好处在于,元数据的读取和更新,时效性非常好,一旦元数据出现了变更,就立即更新到集中式的存储中,其它节点读取的时候就可以感知到;不好在于,所有的元数据的更新压力全部集中在一个地方,可能会导致元数据的存储有压力。

📝 gossip 协议

gossip 协议,所有节点都持有一份元数据,不同的节点如果出现了元数据的变更,就不断将元数据发送给其它的节点,让其它节点也进行元数据的变更。gossip好处在于,元数据的更新比较分散,不是集中在一个地方,更新请求会陆陆续续打到所有节点上去更新,降低了压力;不好在于,元数据的更新有延时,可能导致集群中的一些操作会有一些滞后。

在 Redis cluster 架构下,每个节点都有一个专门用于节点间通信的端口,就是自己提供服务的端口号+10000,每个 Redis 要放开两个端口号,比如 7001,那么用于节点间通信的就是 17001 端口,17001端口号是用来进行节点间通信的,也就是 cluster bus 的东西。每个节点每隔一段时间都会往另外几个节点发送 ping 消息,同时其它几个节点接收到 ping 之后返回 pong 。

🍊 多级缓存架构

🍊 并发竞争

Redis 的并发竞争问题是什么?如何解决这个问题?了解 Redis 事务的 CAS 方案吗?

多客户端同时并发写一个 key,可能本来应该先到的数据后到了,导致数据版本错了;或者是多客户端同时获取一个 key,修改值之后再写回去,只要顺序错了,数据就错了。

CAS 类的乐观锁方案:某个时刻,多个系统实例都去更新某个 key。可以基于 zookeeper 实现分布式锁。每个系统通过 zookeeper 获取分布式锁,确保同一时间,只能有一个系统实例在操作某个 key,别人都不允许读和写。

你要写入缓存的数据,都是从 mysql 里查出来的,都得写入 mysql 中,写入 mysql 中的时候必须保存一个时间戳,从 mysql 查出来的时候,时间戳也查出来。每次要写之前,先判断一下当前这个 value 的时间戳是否比缓存里的 value 的时间戳要新。如果是的话,那么可以写,否则,就不能用旧的数据覆盖新的数据。

🍊 Redis cluster 的高可用与主备切换原理

如果一个节点认为另外一个节点宕机,这是属于主观宕机。如果多个节点都认为另外一个节点宕机了,那么就是客观宕机,跟哨兵的原理几乎一样,sdown,odown。流程为:如果一个节点认为某个节点pfail 了,那么会在 gossip ping 消息中, ping 给其他节点,如果超过半数的节点都认为 pfail 了,那么就会变成fail 。 每个从节点,都根据自己对 master 复制数据的 offset,来设置一个选举时间,offset越大(复制数据越多)的从节点,选举时间越靠前,优先进行选举。所有的 master node 开始 slave 选举投票,给要进行选举的slave 进行投票,如果大部分 master node (N/2 + 1) 都投票给了某个从节点,那么选举通过,那个从节点可以切换成master。从节点执行主备切换,从节点切换为主节点。

🍊 主从架构下的数据同步

🎉 主从复制/数据同步

master会启动一个后台线程,开始生成一份RDB快照文件,同时还会将从客户端收到的所有写命令缓存在内存中。RDB文件生成完毕之后,master会将这个RDB发送给slave,slave会先写入本地磁盘,然后再从本地磁盘加载到内存中。然后master会将内存中缓存的写命令发送给slave,slave也会同步这些数据。

🎉 主从架构下的数据部分复制(断点续传)

当redis是主从架构时,主节点同步数据到从节点进行持久化,这个过程可能会因为网络/IO等原因,导致连接中断,当主节点和从节点断开重连后,一般都会对整份数据进行复制,这个过程是比较浪费性能的。从redis2.8版本开始,redis改用可以支持部分数据复制的命令去主节点同步数据,主节点会在内存中创建一个复制数据用的缓存队列,缓存最近一段时间的数据,主节点和它所有的从节点都维护复制的数据下标和主节点的进程id,当网络连接断开后,从节点会请求主节点继续进行数据同步,从记录数据的下标开始同步数据。如果主节点进程id变化了,或者从节点数据下标太旧,不在主节点的缓存队列里,会进行一次全量数据的复制。

🎉 数据丢失发生的场景以及解决方案

  • 异步复制导致的数据丢失:主节点到从节点的复制是异步的,主节点有部分数据还没复制到从节点,主节点就宕机了。
  • 脑裂导致的数据丢失:脑裂导致的数据丢失:某个 主节点 所在机器突然脱离了正常的网络,跟其他从节点机器不能连接,但是实际上 主节点还运行着,这个时候哨兵可能就会认为 主节点 宕机了,然后开启选举,将其他从节点切换成了 主节点,集群里就会有两个主节点 ,也就是所谓的脑裂。虽然某个从节点被切换成了 主节点,但是可能 client 还没来得及切换到新的主节点,还继续向旧的主节点写数据,当旧的主节点再次恢复的时候,会被作为一个从节点挂到新的 主节点上去,自己的数据会清空,从新的主节点复制数据,新的主节点并没有后来 client写入的数据,这部分数据也就丢失了。

解决方案:

  • 针对异步复制导致的数据丢失,可以通过控制复制数据的时长和ack的时间来控制,一旦从节点复制数据和 ack 延时太长,就认为可能主节点宕机后损失的数据太多了,那么就拒绝写请求,这样可以把主节点宕机时由于部分数据未同步到从节点导致的数据丢失降低的可控范围内。
  • 针对脑裂导致的数据丢失:如果一个主节点出现了脑裂,跟其他从节点断了连接,如果不能继续给从节点发送数据,而且从节点超过10 秒没有给自己ack消息,那么就直接拒绝客户端的写请求,这样即便在脑裂场景下,最多就丢失10 秒的数据。在redis的配置文件里面有二个参数,min-slaves-to-write 3表示连接到master的最少slave数量,min-slaves-max-lag 10表示slave连接到master的最大延迟时间,通过这二个参数可以把数据丢失控制在承受范围以内。

🎉 主从/哨兵/集群区别

📝 主从架构

主数据库可以进行读写操作,当写操作导致数据变化的时候,会自动将数据同步给从数据库,从数据库一般是只读的,接受主数据库同步过来的数据。

📝 哨兵

当主数据库遇到异常中断服务后,需要通过手动的方式选择一个从数据库来升格为主数据库,让系统能够继续提供服务,难以实现自动化。 Redis 2.8中提供了哨兵工具来实现自动化的系统监控和故障恢复功能,哨兵的作用就是监控redis主、从数据库是否正常运行,主数据库出现故障,自动将从数据库转换为主数据库。

📝 集群

即使使用哨兵,redis每个实例也是全量存储,每个redis存储的内容都是完整的数据,浪费内存,有木桶效应。为了最大化利用内存,可以采用集群,就是分布式存储,每台redis存储不同的内容,Redis集群共有16384个槽,每个redis分得一些槽,客户端请求的key,根据公式,计算出映射到哪个分片上。

🎉 高可用/哨兵集群/主备切换

Redis哨兵集群实现高可用,哨兵是一个分布式系统,可以在一个架构中运行多个哨兵进程,这些进程使用流言协议来接收关于主节点是否下线的信息,并使用投票协议来决定是否进行自动故障迁移,选择哪个备节点作为新的主节点。每个哨兵会向其它哨兵、主节点、备节点定时发送消息,以确认对方是否"活"着,如果发现对方在指定时间内未回应,则暂时认为对方已挂。若"哨兵群"中的多数哨兵,都报告某一主节点没响应,系统才认为该主节点"彻底死亡",通过算法,从剩下的备节点中,选一台提升为主节点,然后自动修改相关配置,比如主节点名称,IP,端口号,选举次数,主服务器的密码,心跳检测毫秒数,做多少个节点等。

Redis调优

绑定CPU内核

现代计算机的CPU都是多核心多线程,例如i9-12900k有16个内核、24个逻辑处理器、L1缓存1.4MB、L2缓存14MB、L3缓存30MB,一个内核下的逻辑处理器共用L1和L2缓存。

Redis的主线程处理客户端请求、子进程进行数据持久化、子线程处理RDB/AOF rewrite、后台线程处理异步lazy-free和异步释放fd等。这些线程在多个逻辑处理器之间切换,所以为了降低Redis服务端在多个CPU内核上下文切换带来的性能损耗,Redis6.0版本提供了进程绑定CPU 的方式提高性能。

在Redis6.0版本的redis.conf文件配置即可:

  • server_cpulist:RedisServer和IO线程绑定到CPU内核
  • bio_cpulist:后台子线程绑定到CPU内核
  • aof_rewrite_cpulist:后台AOF rewrite进程绑定到CPU内核
  • bgsave_cpulist:后台RDB进程绑定到CPU内核

使用复杂度过高的命令

Redis有些命令复制度很高,复杂度过高的命令如下:

java 复制代码
MSET、MSETNX、MGET、LPUSH、RPUSH、LRANGE、LINDEX、LSET、LINSERT、HDEL、HGETALL、HKEYS/HVALS、SMEMBERS、
SUNION/SUNIONSTORE、SINTER/SINTERSTORE、SDIFF/SDIFFSTORE、ZRANGE/ZREVRANGE、ZRANGEBYSCORE/ZREVRANGEBYSCORE、
ZREMRANGEBYRANK/ZREMRANGEBYSCORE、DEL、KEYS

具体原因有以下:

  • 在内存操作数据的时间复杂度太高,消耗的CPU资源较多。
  • 一些范围命令一次返回给客户端的数据太多,在数据协议的组装和网络传输的过程就要变长,容易延时。
  • Redis虽然使用了多路复用技术,但是复用的还是同一个线程,这一个线程同一时间只能处理一个IO事件,像一个开关一样,当开关拨到哪个IO事件这个电路上,就处理哪个IO事件,所以它单线程处理客户端请求的,如果前面某个命令耗时比较长,后面的请求就会排队,对于客户端来说,响应延迟也会变长。

解决方案:

分批次,每次获取尽量少的数据,数据的聚合在客户端做,减少服务端的压力。

大key的存储和删除

当存储一个很大的键值对的时候,由于值非常大,所以Redis分配内存的时候就会很耗时,此外删除这个key也是一样耗时,这种key就是大key。开发者可以通过设置慢日志记录有哪些命令比较耗时,命令如下:

命令执行耗时超过10毫秒,记录慢日志

java 复制代码
CONFIG SET slowlog-log-slower-than 10000

只保留最近1000条慢日志

java 复制代码
CONFIG SET slowlog-max-len 1000

后面再通过SLOWLOG get [n]查看。

对于大key可以通过以下命令直接以类型展示出来,它只显示元素最多的key,但不代表占用内存最多,命令如下:

java 复制代码
##-h:redis主机ip
##-p: redis端口号
##-i:隔几秒扫描
redis-cli -h 127.0.0.1 -p 6379 --bigkeys -i 0.01

对于这种大key的优化,开发者事先在业务实现层就需要避免存储大key,可以在存储的时候将key简化,变成二进制位进行存储,节约redis空间,例如存储上海市静安区,可以对城市和区域进行编码,上海市标记为0,静安区标记为1,组合起来就是01,将01最为key存储起来比上海市静安区作为key存储起来内存占比更小。

可以将大key拆分成多个小key,整个大key通过程序控制多个小key,例如初始阶段,业务方只需查询某乡公务员姓名。然而,后续需求拓展至县、市、省。开发者未预见此增长,将数据存储于单个键中,导致键变成大键,影响系统性能。现可将大键拆分成多个小键,如省、市、县、乡,使得每级行政区域的公务员姓名均对应一个键。

根据Redis版本不同处理方式也不同,4.0以上版本可以用unlink代替del,这样可以把key释放内存的工作交给后台线程去执行。6.0以上版本开启lazy-free后,执行del命令会自动地在后台线程释放内存。

使用List集合时通过控制列表保存元素个数,每个元素长度触发压缩列表(ziplist)编码,压缩列表是有顺序并且连续的内存块组成的一种专门节约内存的存储结构,通过在redis.conf(linux系统)或者redis.windows.conf(windows系统)文件里面修改以下配置实现:

java 复制代码
list-max-ziplist-entries 512
list-max-ziplist-value 64

数据集中过期

在某个时段,大量关键词(key)会在短时间内过期。当这些关键词过期时,访问Redis的速度会变慢,因为过期数据被惰性删除(被动)和定期删除(主动)策略共同管理。惰性删除是在获取关键词时检查其是否过期,一旦过期就删除。

这意味着大量过期关键词在使用之前并未删除,从而持续占用内存。主动删除则是在主线程执行,每隔一段时间删除一批过期关键词。若出现大量需要删除的过期关键词,客户端访问Redis时必须等待删除完成才能继续访问,导致客户端访问速度变慢。

这种延迟在慢日志中无法查看,经验不足的开发者可能无法定位问题,因为慢日志记录的是操作内存数据所需时间,而主动删除过期关键词发生在命令执行之前,慢日志并未记录时间消耗。

因此,当开发者感知某个关键词访问变慢时,实际上并非该关键词导致,而是Redis在删除大量过期关键词所花费的时间。

(1)开发者检查代码,找到导致集中过期key的逻辑,并设置一个自定义的随机过期时间分散它们,从而避免在短时间内集中删除key。

(2)在Redis 4.0及以上版本中,引入了Lazy Free机制,使得删除键的操作可以在后台线程中执行,不会阻塞主线程。

(3)使用Redis的Info命令查看Redis运行的各种指标,重点关注expired_keys指标。这个指标在短时间内激增时,可以设置报警,通过短信、邮件、微信等方式通知运维人员。它的作用是累计删除过期key的数量。当指标突增时,通常表示大量过期key在同一时间被删除。

内存淘汰策略

当Redis的内存达到最大容量限制时,新的数据将先从内存中筛选一部分旧数据以腾出空间,从而导致写操作的延迟。这是由内存淘汰策略所决定的。

常见的两种策略为淘汰最少访问的键(LFU)和淘汰最长时间未访问的键(LRU)。

  • LRU策略可能导致最近一段时间的访问数据未被访问而突然成为热点数据。
  • LFU策略可能导致前一段时间访问次数很多,但最近一段时间未被访问,导致冷数据无法被淘汰。

尽管LFU策略的性能优于LRU策略,但具体选择哪种策略需要根据实际业务进行调整。对于商品搜索和热门推荐等场景,通常只有少量数据被访问,大部分数据很少被访问,可以使用LFU策略。对于用户最近访问的页面数据可能会被二次访问的场景,则适合使用LRU策略。

除了选择淘汰策略外,还可以通过拆分多个实例或横向扩展来分散淘汰过期键的压力。

如果效果仍不理想,开发者可以编写淘汰过期键功能,设置定时任务,在凌晨不繁忙时段主动触发淘汰,删除过期键。

碎片整理

Redis存储在内存中必然会出现频繁修改的情况,而频繁的修改Redis数据会导致Redis出现内存碎片,从而导致Redis的内存使用率减低。

通常情况下在4.0以下版本的Redis只能通过重启解决内存碎片,而4.0及以上版本可以开启碎片自动整理解决,只不过碎片整理是在

主线程中完成的,通用先对延时范围和时间进行评估,然后在机器负载不高同时业务不繁忙时开启内存碎片整理,避免影响客户端请求。

开启内存自动碎片整理配置如下:

java 复制代码
## 已启用活动碎片整理
activedefrag yes
## 启动活动碎片整理所需的最小碎片浪费量
active-defrag-ignore-bytes 100mb
## 启动活动碎片整理的最小碎片百分比
active-defrag-threshold-lower 10
## 使用最大努力的最大碎片百分比
active-defrag-threshold-upper 100
## 以CPU百分比表示的碎片整理工作量最小
active-defrag-cycle-min 5
## 以CPU百分比表示的碎片整理最大工作量
active-defrag-cycle-max 75
## 将从主字典扫描中处理的集合/哈希/zset/列表字段的最大数目
active-defrag-max-scan-fields 1000

内存大页

自Linux内核2.6.38版本起,Redis可申请以2MB为单位的内存,从而降低内存分配次数,提高效率。然而,由于每次分配的内存单位增大,处理时间也相应增加。在进行RDB和AOF持久化时,Redis主进程先创建子进程,子进程将内存快照写入磁盘,而主进程继续处理写请求。数据变动时,主进程将新数据复制到一块新内存,并修改该内存块。

读写分离设计允许并发写入,无需加锁,但在主进程上进行内存复制和申请新内存会增加处理时间,影响性能。大key可能导致申请更大的内存和更长的处理时间。根据项目实际情况,关闭Redis部署机器上的内存大页机制以提高性能是一种不错的选择。

数据持久化与AOF刷盘

Redis提供三种持久化方式:RDB快照、AOF日志和混合持久化。默认使用RDB快照。

(1)RDB快照:周期性生成dump.rdb文件,主线程fork子线程,子线程处理磁盘IO,处理RDB快照,主线程fork线程的过程可能会阻塞主线程,主线程内存越大阻塞越久,可能导致服务暂停数秒。

(2)AOF日志:每条写入命令追加,回放日志重建数据。文件过大时,会去除没用的指令,定期根据内存最新数据重新生成aof文件。默认1秒执行一次fsync操作,最多丢失1秒数据。在AOF刷盘时,如果磁盘IO负载过高,fsync可能会阻塞主线程,主线程继续接收写请求,把数据写到文件内存里面,写操作需要等fsync执行完才可以继续执行。

(3)混合持久化:RDB快照模式恢复速度快,但可能丢失部分数据。AOF日志文件通常比RDB数据快照文件大,支持的写QPS较低。将两种持久化模式混合使用,AOF保证数据不丢失,RDB快速数据恢复,混合持久化重写时,将内存数据转换为RESP命令写入AOF文件,结合RDB快照和增量AOF修改。新文件一开始不叫appendonly.aof,重写完成后改名,覆盖原有AOF文件。先加载RDB,再重放AOF。

三种持久化方式都存在问题:fork操作可能阻塞主线程;磁盘IO负载过大时,fork阻塞影响AOF写入文件内存。

原因:fork创建的子进程复制父进程的空间内存页表,fork耗时跟进程总内存量有关,OPS越高耗时越明显。

解决方案:

(1)可以通过info stats命令查看latest_fork_usec指标观察最近一次fork操作耗时进行问题辅助定位。

(2)减少fork频率,根据实际情况适当地调整AOF触发条件

(3)Linux内存分配策略默认配置是vm.overcommit_memory=0,表示内存不足时,不会分配,导致fork阻塞。改成1,允许过量使用,直到内存用完为止。

(4)评估Redis最大可用内存,让机器至少有20%的闲置内存。

(5)定位占用磁盘IO较大的应用程序,将该应用程序移到其他机器上去,减少对Redis影响。

(6)资金充足情况下,更换高性能的SSD磁盘,从硬件层面提高磁盘IO处理能力。

(7)配置no-appendfsync-on-rewrite none表示AOF写入文件内存时,不触发fsync,不执行刷盘。这种调整有一定风险,如果Redis在AOF写入文件内存时刚好挂了,存在数据丢失情况。

丢包/中断/CPU亲和性

网络因素有以下问题:

(1)网络宽带和流量是否瓶颈、数据传输延迟和丢包情况、是否频繁短连接(如TCP创建和断开)

(2)数据丢包情况:数据丢包通常发生在网卡设备驱动层面,网卡收到数据包,将数据包从网卡硬件缓存转移到服务器内存中,通知内核处理,经过TCP/IP协议校验、解析、发送给上层协议,应用程序通过read系统调用从socket buffer将新数据从内核区拷贝到用户区读取数据。

TCP能动态调整接收窗口大小,不会出现由于socket buffer接收队列空间不足而丢包的情况。

然而在高负载压力下,网络设备的处理性能达到硬件瓶颈,网络设备和内核资源出现竞争和冲突,网络协议栈无法有效地处理和转发数据包,传输速度受限,而Linux使用缓冲区来缓存接收到的数据包,大量数据包涌入内核缓冲区,可能导致缓冲区溢出,进而影响数据包的处理和传输,内核无法处理所有收到的数据包,处理速度跟不上收包速度,导致数据包丢失。

(3)Redis的数据通常存储在内存中,通过网络和客户端进行交互。在这个过程中,Redis可能会受到中断的影响,因为中断可能会打断Redis的正常执行流程。当CPU正在处理Redis的调用时,如果发生了中断,CPU必须停止当前的工作转而处理中断请求。在处理中断的过程中,Redis无法继续运行,必须等待中断处理完毕后才能继续运行。这会导致Redis的响应速度受到影响,因为在等待中断处理的过程中,Redis无法响应其他请求。

(4)在NUMA架构中,每个CPU内核对应一个NUMA节点。中断处理和网络数据包处理涉及多个CPU内核和NUMA节点。Linux内核使用softnet_data数据结构跟踪网络数据包的处理状态,以实现更高效的数据处理和调度。在处理网络数据包时,内核首先在softnet_data中查找相关信息,然后根据这些信息执行相应操作,如发送数据包、重新排序数据包等。

网络驱动程序使用内核分配的缓冲区(sk_buffer)存储和处理网络数据包,当网络设备收到数据包时,会向驱动程序发送中断信号,通知其处理新数据包。驱动程序从设备获取数据包,并将其添加到sk_buffer缓冲区。内核会继续处理sk_buffer中的数据包,如根据协议类型进行分拣、转发或丢弃等。

softnet_data和sk_buffer缓冲区都可能跨越NUMA节点,在数据接收过程中,数据从NUMA节点的一个节点传递到另一个节点时,由于数据跨越了不同的节点,不仅无法利用L2和L3缓存还需要在节点之间进行数据拷贝,导致数据在传输过程中的额外开销,进而增加了传输时间和响应时间,性能下降。

(5)Linux的CPU亲和性特性也会影响进程的调度。当一个进程唤醒另一个的时候,被唤醒的进程可能会被放到相同的CPU core或者相同的NUMA节点上。当多个NUMA node处理中断时,可能导致Redis进程在CPU core之间频繁迁移,造成性能损失。

解决方案:

(1)升级网络设备或增加网络设备的数量,以提高网络处理能力和带宽。

(2)适当调整Linux内核缓冲区的大小,以平衡网络处理能力和数据包丢失之间的关系。

(3)将中断都分配到同一NUMA Node中,中断处理函数和Redis利用同NUMA下的L2、L3缓存、同节点下的内存,降低延迟。

(4)结合Linux的CPU亲和性特性,将任务或进程固定到同一CPU内核上运行,提高系统性能和效率,保证系统稳定性和可靠性。

注意:在Linux系统中NUMA亲和性可以指定在哪个NUMA节点上运行,Redis在默认情况下并不会自动将NUMA亲和性配置应用于实例部署,通常情况下通过使用Kubernetes等容器编排工具,调整节点亲和性策略或使用pod亲和性和节点亲和性规则来控制Redis实例在特定NUMA节点上运行。或者在手动部署Redis实例时,使用Linux系统中的numactl命令来查看和配置NUMA节点信息,将Redis实例部署在某个NUMA节点上。如果是在虚拟化环境中,使用NUMA aware虚拟机来部署Redis实例,让它在指定的NUMA节点上运行。

(5)添加网络流量阈值预警,超限时通知运维人员,及时扩容。

(6)编写监控脚本,正确配置和使用监控组件,使用长连接收集Redis状态信息,避免短连接。

(7)为Redis机器分配专用资源,避免其他程序占用。

操作系统Swap与主从同步

Redis突然变得很慢,需要考虑Redis是否使用操作系统的Swap以缓解内存不足的影响,它允许把部分内存数据存储到磁盘上,而访问磁盘速度比访问内存慢很多,所以操作系统的Swap对Redis的延时是无法接受的。

解决方案:

(1)适当增加Redis服务器的内存

(2)对Redis的内存碎片进行整理

(3)同时当Redis内存不足或者使用了Swap时,通过邮件、短信、微信等渠道通知运维人员及时处理

(4)主从架构的Redis在释放Swap前先将主节点切换至新主节点,旧主节点释放Swap后重启,待从库数据完全同步后再行主从切换,以避免影响应用程序正常运行。

在主从架构数据同步过程中,可能因网络中断或IO异常导致连接中断。建议使用支持数据断点续传的2.8及以上版本,以避免对整份数据进行复制,降低性能浪费。

监控

在Redis的监控中,有两种推荐的体系:ELK和Fluent + Prometheus + Grafana。

ELK体系通常使用metricbeat作为指标采集,logstash作为收集管道,并通过可视化工具kibana来呈现数据。ElasticSearch用于存储监控数据。

Fluent + Prometheus + Grafana体系则使用redis-eport作为指标采集,fluentd作为采集管道,并通过可视化工具Grafana来展示数据。Prometheus用于存储监控数据。

这两种监控体系都可以获取Redis的各项指标,并对数据进行持续化存储和对比。可视化工具使得开发者和运维人员能够更清晰地观察Redis集群的运行状况,如内存消耗、集群信息、请求键命中率、客户端连接数、网络指标、内存监控等。此外,它们都支持预警机制,例如设置慢查询日志阈值来监控慢日志个数和最长耗时,超出阈值则通过短信、微信、邮件等方式进行报警通知。这样,有了监控系统后,就可以快速发现问题、定位故障,并协助运维人员进行资源规划、性能观察等操作。

高可用

上述提到的主从同步和哨兵机制可以保证Redis服务的高可用,还有多级缓存、冷热分离可以保证高可用。

商品详情页在电商平台的秒杀场景中,涉及商品信息的动态展示和高并发访问,需要通过一系列手段保证系统的高并发和高可用,通过采用Nginx+Lua架构、CDN缓存、本地应用缓存和分布式缓存等多种技术手段,实现了商品详情页的动态化和缓存优化,提高用户访问商品详情页的速度和体验。同时,通过开关前置化和缓存过期机制,确保了缓存数据的有效性,降低了对后端数据库的访问压力。

主从同步和哨兵机制

主从复制通常采用异步方式,可能导致主节点数据尚未完全复制至从节点,主节点便已故障,导致数据丢失。因此,需要控制复制数据的时长和ACK延迟,降低数据丢失风险。

主从切换过程通常使用哨兵机制。但在主节点正常运行时,可能因与某从节点连接中断,哨兵误判主节点已故障。在此情况下,哨兵可能启动选举,将某从节点升级为主节点,导致集群出现两个主节点,发生脑裂。旧主节点恢复网络后,将被升级为从节点并挂载至新主节点,导致自身数据丢失,并需从新主节点复制数据。而新主节点并未包含后续客户端写入的数据,导致这些数据丢失。为降低数据丢失风险,可设置连接主节点最少的从节点数量和从节点连接主节点最大的延迟时间,若主节点与从节点断开连接,且从节点超过阈值时间未收到ACK消息,则拒绝客户端的写请求,将数据丢失控制在可控范围。

多级缓存

Java多级缓存是一种常见的优化策略,可以有效地提高系统的性能和响应速度。

1.浏览器缓存

在页面间跳转时,从本地缓存获取数据;或在打开新页面时,根据Last-Modified头来CDN验证数据是否过期,减少数据传输量。

CDN缓存当用户点击商品图片或链接时,从最近的CDN节点获取数据,而非回源到北京机房,提升访问性能。

2.服务端应用本地缓存

采用Nginx+Lua架构,通过HttpLuaModule模块的shared dict或内存级Proxy Cache来减少带宽。

3.一致性哈希

在电商场景中,使用商品编号/分类作为哈希键,提高URL命中率。

4.mget优化

根据商品的其他维度数据(如分类、面包屑、商家等),先从本地缓存读取,如不命中则从远程缓存获取。这个优化减少了一半以上的远程缓存流量。

5.服务端缓存

(1)将缓存存储在内存、SSD和JIMDB中,实现读写性能和持久化的平衡。

(2)对热门商品和访问量较大的页面进行缓存,降低数据库压力。

(3)使用Nignx缓存:存储数据量少但访问量高的热点数据,例如双11或者618活动。

(4)使用JVM本地缓存:存储数据量适中访问量高的热点数据,例如网站首页数据。

(5)使用Redis缓存:存储数据量很大,访问量较高的普通数据,例如商品信息。

6.商品详情页数据获取

(1)用户打开商品详情页时,先从本地缓存获取基本数据,如商品ID、商品名称和价格等。

(2)根据用户浏览历史和搜索记录,动态加载其他维度数据,如分类、商家信息和评论等。

7.Nginx+Lua架构

(1)使用Nginx作为反向代理和负载均衡器,将请求转发给后端应用。

(2)使用Lua脚本实现动态页面渲染,并对商品详情页数据进行缓存。

(3)重启应用秒级化,重启速度快,且不会丢失共享字典缓存数据。

(4)需求上线速度化,可以快速上线和重启应用,减少抖动。

(5)在Nginx上做开关,设置缓存过期时间,当缓存数据过期时,强制从后端应用获取最新数据,并更新缓存。

冷热分离

冷热分离的具体步骤:

(1)分析现有系统的数据类型和访问模式,了解各类数据的冷热程度。

(2)确定合适的冷热分离策略和方案,以优化数据存储和管理。

(3)设计冷热分离架构,为热数据和冷数据选择合适的存储介质、存储策略以及数据同步机制。

(4)将冷数据从热存储介质迁移到冷存储介质,可以采用全量迁移和增量迁移的方式。

(5)对热数据进行有效管理,包括访问控制、数据安全、性能监控等,以确保数据的安全性和可用性。

(6)对冷数据进行持久化、备份、归档等操作,以防止数据丢失并确保数据的可恢复性。

(7)设计合适的故障转移和恢复策略,如主从复制、多副本存储、故障检测与恢复等,以确保系统在故障或恢复时的稳定运行。

(8)在冷热分离后对系统性能进行优化,包括优化热存储介质的性能监控、调整存储结构、调整缓存策略等。

(9)持续监控数据同步、性能指标、故障排查与修复,确保系统的稳定运行。

以实际案例进行说明:

案例1:在线购物网站的商品库存管理系统

(1)热数据:用户频繁访问的商品信息,如商品名称、价格、库存量等,需要快速响应和低延迟。

(2)冷数据:用户访问较少的商品信息,对响应速度要求较低,但对数据安全和完整性要求较高。如商品的详细描述、评价、历史价格等。

案例2:在线音乐平台的曲库管理系统

(1)热数据:用户经常访问的热门歌曲,如排行榜前10名、新上架的歌曲等,存储在高速且高可靠性的SSD硬盘Redis缓存中,以确保快速的数据访问和响应速度。

(2)冷数据:用户较少访问的歌曲,如过时的经典歌曲、小众音乐等,存储在低成本且大容量的存储介质(HDFS、Ceph、S3)中,以节省成本并存储大量历史数据。

案例3:在线求职招聘网站的职位信息管理系统

(1)热数据:用户经常访问的热门职位信息,如招聘需求高的职位、高薪职位、职位信息的基本描述、薪资范围、投递人数等。

(2)冷数据:用户较少访问的职位信息,如停招职位的详细描述、过期职位、历史招聘情况等。

小结:

在冷数据(如历史数据、归档数据等)存储场景中,使用RocksDB作为Key-Value分布式存储引擎,存储大量数据,进行数据备份和恢复,以确保在故障或系统恢复时能够快速恢复数据,节省成本并提高存储空间利用率。

在热数据(如实时更新的数据、用户操作日志等)存储场景中,使用Redis缓存支持各种高并发场景,提升响应速度。

通过以上步骤,可以有效地对冷热数据进行分离,从而实现更高效、更安全的数据存储和管理。

缓存雪崩、穿透、击穿、热点缓存重构、缓存失效

从前,有一个叫做小明的程序员,他的网站被越来越多的用户访问,于是他决定使用Redis缓存来提高网站性能。

一天,大雪纷飞,小明的服务器突然停机了。当服务器重新启动后,所有的缓存都失效了。这就是Redis缓存雪崩的场景。

为了避免Redis缓存雪崩,小明决定使用多级缓存和缓存预热等技术手段。他设置了多个Redis实例,同时监听同一个缓存集群。当一个实例出现问题时,其他实例可以顶替它的功能。并且,他在低访问时间段主动向缓存中写入数据,以提前预热缓存。

然而,小明并没有想到缓存穿透的问题。有些用户在请求缓存中不存在的数据时,会频繁地向数据库查询,从而拖慢服务器响应时间。这就是Redis缓存穿透的场景。

为了避免Redis缓存穿透,小明决定使用布隆过滤器等技术手段。布隆过滤器可以高效地过滤掉不存在的数据,从而减少数据库查询次数。

不久之后,小明又遇到了缓存击穿的问题。某一个热门商品被多个用户同时请求,导致缓存无法承受压力,最终请求直接打到了数据库。这就是Redis缓存击穿的场景。

为了避免Redis缓存击穿,小明决定使用分布式锁等技术手段。分布式锁可以保证同一时间只有一个用户请求数据库,避免了缓存被高并发压垮的情况。

最后,小明遇到了缓存热点重构的问题。某一个商品的热度突然升高,导致缓存集中在这个商品上,其他商品的缓存无法承受压力。这就是缓存热点重构的场景。

为了避免缓存热点重构,小明决定使用数据预热等技术手段。他在缓存中设置过期时间,同时在低访问时间段主动重构热点商品的缓存,以避免缓存集中在某一个商品上。

技术解决方案和手段:

(1)多级缓存和缓存预热:适用于缓存雪崩场景,可以提前将数据存储到缓存中,避免缓存雪崩。

(2)布隆过滤器:适用于缓存穿透场景,可以高效地过滤不存在的数据,减少数据库查询次数。

(3)分布式锁:适用于缓存击穿场景,可以保证同一时间只有一个用户请求数据库,避免了缓存被高并发压垮的情况。

(4)数据预热:适用于缓存热点重构场景,可以在低访问时间段主动重构热点商品的缓存,避免缓存集中在某一个商品上。

优缺点对比:

(1)多级缓存和缓存预热:优点是能够提前将数据存储到缓存中,避免缓存雪崩;缺点是需要占用更多的内存空间,同时预热时间过长可能会拖慢服务器响应速度。

(2)布隆过滤器:优点是可以高效地过滤不存在的数据,减少数据库查询次数;缺点是无法完全避免缓存穿透,同时需要占用一定的内存空间。

(3)分布式锁:优点是可以保证同一时间只有一个用户请求数据库,避免了缓存被高并发压垮的情况;缺点是会增加系统的复杂度,可能引入单点故障等问题。

(4)数据预热:优点是可以避免缓存热点重构的问题;缺点是需要占用更多的内存空间,同时需要在低访问时间段主动重构缓存。

总之,不同的技术解决方案和手段都有其优缺点。程序员需要根据实际情况选择适合自己的方案,并且不断地优化和改进,以提高系统的性能和稳定性。

MQ消息中间件

解决过各种消息通讯场景的疑难问题,消息中间件(Kafka、RabbitMQ、RocketMQ)出现的消息可靠投递、消息丢失、消息顺序性、消息延迟、过期失效、消息队列满了、消息高可用等问题都有着不错的实战解决方案。有MQ调优经验,如CPU、内存、磁盘、网络、操作系统、MQ本身配置优化等。

消息中间件调优

三种mq对比

使用消息队列有解耦,扩展性,削峰,异步等功能,市面上主流的几款mq,rabbitmq,rocketmq,kafka有各自的应用场景。kafka,有出色的吞吐量,比较强悍的性能,而且集群可以实现高可用,就是会丢数据,所以一般被用于日志分析和大数据采集。rabbitmq,消息可靠性比较高,支持六种工作模式,功能比较全面,但是由于吞吐量比较低,消息累积还会影响性能,加上erlang语言不好定制,所以一般使用于小规模的场景,大多数是中小企业用的比较多。rocketmq,高可用,高性能,高吞吐量,支持多种消息类型,比如同步,异步,顺序,广播,延迟,批量,过滤,事务等等消息,功能比较全面,只不过开源版本比不上商业版本的,加上开发这个中间件的大佬写的文档不多,文档不太全,这也是它的一个缺点,不过这个中间件可以作用于几乎全场景。

消息丢失

消息丢失,生产者往消息队列发送消息,消息队列往消费者发送消息,会有丢消息的可能,消息队列也有可能丢消息,通常MQ存盘时都会先写入操作系统的缓存页中,然后再由操作系统异步的将消息写入硬盘,这个中间有个时间差,就可能会造成消息丢失,如果服务挂了,缓存中还没有来得及写入硬盘的消息就会发生消息丢失。

不同的消息中间件对于消息丢失也有不同的解决方案,先说说最容易丢失消息的kafka吧。生产者发消息给Kafka Broker:消息写入Leader后,Follower是主动与Leader进行同步,然后发ack告诉生产者收到消息了,这个过程kafka提供了一个参数,request.required.acks属性来确认消息的生产。

0表示不进行消息接收是否成功的确认,发生网络抖动消息丢了,生产者不校验ACK自然就不知道丢了。

1表示当Leader接收成功时确认,只要Leader存活就可以保证不丢失,保证了吞吐量,但是如果leader挂了,恰好选了一个没有ACK的follower,那也丢了。

-1或者all表示Leader和Follower都接收成功时确认,可以最大限度保证消息不丢失,但是吞吐量低,降低了kafka的性能。一般在不涉及金额的情况下,均衡考虑可以使用1,保证消息的发送和性能的一个平衡。

Kafka Broker 消息同步和持久化:Kafka通过多分区多副本机制,可以最大限度保证数据不会丢失,如果数据已经写入系统缓存中,但是还没来得及刷入磁盘,这个时候机器宕机,或者没电了,那就丢消息了,当然这种情况很极端。Kafka Broker 将消息传递给消费者:如果消费这边配置的是自动提交,万一消费到数据还没处理完,就自动提交offset了,但是此时消费者直接宕机了,未处理完的数据丢失了,下次也消费不到了。所以为了避免这种情况,需要将配置改为,先消费处理数据,然后手动提交,这样消息处理失败,也不会提交成功,没有丢消息。

rabbitmq整个消息投递的路径是producer--->rabbitmq broker--->exchange--->queue--->consumer。

生产者将消息投递到Broker时产生confirm状态,会出现二种情况,ack:表示已经被Broker签收。nack:表示表示已经被Broker拒收,原因可能有队列满了,限流,IO异常等。生产者将消息投递到Broker,被Broker签收,但是没有对应的队列进行投递,将消息回退给生产者会产生return状态。

这二种状态是rabbitmq提供的消息可靠投递机制,生产者开启确认模式和退回模式。使用rabbitTemplate.setConfirmCallback设置回调函数。当消息发送到exchange后回调confirm方法。在方法中判断ack,如果为true,则发送成功,如果为false,则发送失败,需要处理。

使用rabbitTemplate.setReturnCallback设置退回函数,当消息从exchange路由到queue失败后,如果设置了rabbitTemplate.setMandatory(true)参数,则会将消息退回给producer。消费者在rabbit:listener-container标签中设置acknowledge属性,设置ack方式 none:自动确认,manual:手动确认。

none自动确认模式很危险,当生产者发送多条消息,消费者接收到一条信息时,会自动认为当前发送的消息已经签收了,这个时候消费者进行业务处理时出现了异常情况,也会认为消息已经正常签收处理了,而队列里面显示都被消费掉了。所以真实开发都会改为手动签收,可以防止消息丢失。

消费者如果在消费端没有出现异常,则调用channel.basicAck方法确认签收消息。消费者如果出现异常,则在catch中调用 basicNack或 basicReject,拒绝消息,让MQ重新发送消息。通过一系列的操作,可以保证消息的可靠投递以及防止消息丢失的情况。

然后说一下rocketmq,生产者使用事务消息机制保证消息零丢失,第一步就是确保Producer发送消息到了Broker这个过程不会丢消息。发送half消息给rocketmq,这个half消息是在生产者操作前发送的,对下游服务的消费者是不可见的。这个消息主要是确认RocketMQ的服务是否正常,通知RocketMQ,马上要发一个消息了,做好准备。

half消息如果写入失败就认为MQ的服务是有问题的,这个时候就不能通知下游服务了,给生产者的操作加上一个状态标记,然后等待MQ服务正常后再进行补偿操作,等MQ服务正常后重新下单通知下游服务。

然后执行本地事务,比如说下了个订单,把下单数据写入到mysql,返回本地事务状态给rocketmq,在这个过程中,如果写入数据库失败,可能是数据库崩了,需要等一段时间才能恢复,这个时候把订单一直标记为"新下单"的状态,订单的消息先缓存起来,比如Redis、文本或者其他方式,然后给RocketMQ返回一个未知状态,未知状态的事务状态回查是由RocketMQ的Broker主动发起的,RocketMQ过一段时间来回查事务状态,在回查事务状态的时候,再尝试把数据写入数据库,如果数据库这时候已经恢复了,继续后面的业务。

而且即便这个时候half消息写入成功后RocketMQ挂了,只要存储的消息没有丢失,等RocketMQ恢复后,RocketMQ就会再次继续状态回查的流程。

第二步就是确保Broker接收到的消息不会丢失,因为RocketMQ为了减少磁盘的IO,会先将消息写入到os缓存中,不是直接写入到磁盘里面,消费者从os缓存中获取消息,类似于从内存中获取消息,速度更快,过一段时间会由os线程异步的将消息刷入磁盘中,此时才算真正完成了消息的持久化。在这个过程中,如果消息还没有完成异步刷盘,RocketMQ中的Broker宕机的话,就会导致消息丢失。

所以第二步,消息支持持久化到Commitlog里面,即使宕机后重启,未消费的消息也是可以加载出来的。把RocketMQ的刷盘方式 flushDiskType配置成同步刷盘,一旦同步刷盘返回成功,可以保证接收到的消息一定存储在本地的内存中。采用主从机构,集群部署,Leader中的数据在多个Follower中都存有备份,防止单点故障,同步复制可以保证即使Master 磁盘崩溃,消息仍然不会丢失。但是这里还会有一个问题,主从结构是只做数据备份,没有容灾功能的。也就是说当一个master节点挂了后,slave节点是无法切换成master节点继续提供服务的。

所以在RocketMQ4.5以后的版本支持Dledge,DLedger是基于Raft协议选举Leader Broker的,当master节点挂了后,Dledger会接管Broker的CommitLog消息存储 ,在Raft协议中进行多台机器的Leader选举,发起一轮一轮的投票,通过多台机器互相投票选出来一个Leader,完成master节点往slave节点的消息同步。

数据同步会通过两个阶段,一个是uncommitted阶段,一个是commited阶段。Leader Broker上的Dledger收到一条数据后,会标记为uncommitted状态,然后他通过自己的DledgerServer组件把这个uncommitted数据发给Follower Broker的DledgerServer组件。

接着Follower Broker的DledgerServer收到uncommitted消息之后,必须返回一个ack给Leader Broker的Dledger。然后如果Leader Broker收到超过半数的Follower Broker返回的ack之后,就会把消息标记为committed状态。再接下来, Leader Broker上的DledgerServer就会发送committed消息给Follower Broker上的DledgerServer,让他们把消息也标记为committed状态。这样,就基于Raft协议完成了两阶段的数据同步。

第三步,Cunmser确保拉取到的消息被成功消费,就需要消费者不要使用异步消费,有可能造成消息状态返回后消费者本地业务逻辑处理失败造成消息丢失的可能。用同步消费方式,消费者端先处理本地事务,然后再给MQ一个ACK响应,这时MQ就会修改Offset,将消息标记为已消费,不再往其他消费者推送消息,在Broker的这种重新推送机制下,消息是不会在传输过程中丢失的。

消息重复消费

消息重复消费的问题

第一种情况是发送时消息重复,当一条消息已被成功发送到服务端并完成持久化,此时出现了网络抖动或者客户端宕机,导致服务端对客户端应答失败。 如果此时生产者意识到消息发送失败并尝试再次发送消息,消费者后续会收到两条内容相同并且 Message ID 也相同的消息。

第二种情况是投递时消息重复,消息消费的场景下,消息已投递到消费者并完成业务处理,当客户端给服务端反馈应答的时候网络闪断。 为了保证消息至少被消费一次,tMQ 的服务端将在网络恢复后再次尝试投递之前已被处理过的消息,消费者后续会收到两条内容相同并且 Message ID 也相同的消息。

第三种情况是负载均衡时消息重复,比如网络抖动、Broker 重启以及订阅方应用重启,当MQ的Broker或客户端重启、扩容或缩容时,会触发Rebalance,此时消费者可能会收到重复消息。

那么怎么解决消息重复消费的问题呢?就是对消息进行幂等性处理。

在MQ中,是无法保证每个消息只被投递一次的,因为网络抖动或者客户端宕机等其他因素,基本都会配置重试机制,所以要在消费者端的业务上做消费幂等处理,MQ的每条消息都有一个唯一的MessageId,这个参数在多次投递的过程中是不会改变的,业务上可以用这个MessageId加上业务的唯一标识来作为判断幂等的关键依据,例如订单ID。而这个业务标识可以使用Message的Key来进行传递。消费者获取到消息后先根据id去查询redis/db是否存在该消息,如果不存在,则正常消费,消费完后写入redis/db。如果存在,则证明消息被消费过,直接丢弃。

消息顺序

消息顺序的问题,如果发送端配置了重试机制,mq不会等之前那条消息完全发送成功,才去发送下一条消息,这样可能会出现发送了1,2,3条消息,但是第1条超时了,后面两条发送成功,再重试发送第1条消息,这时消息在broker端的顺序就是2,3,1了。RocketMQ消息有序要保证最终消费到的消息是有序的,需要从Producer、Broker、Consumer三个步骤都保证消息有序才行。

在发送者端:在默认情况下,消息发送者会采取Round Robin轮询方式把消息发送到不同的分区队列,而消费者消费的时候也从多个MessageQueue上拉取消息,这种情况下消息是不能保证顺序的。而只有当一组有序的消息发送到同一个MessageQueue上时,才能利用MessageQueue先进先出的特性保证这一组消息有序。

而Broker中一个队列内的消息是可以保证有序的。在消费者端:消费者会从多个消息队列上去拿消息。这时虽然每个消息队列上的消息是有序的,但是多个队列之间的消息仍然是乱序的。消费者端要保证消息有序,就需要按队列一个一个来取消息,即取完一个队列的消息后,再去取下一个队列的消息。而给consumer注入的MessageListenerOrderly对象,在RocketMQ内部就会通过锁队列的方式保证消息是一个一个队列来取的。

MessageListenerConcurrently这个消息监听器则不会锁队列,每次都是从多个Message中取一批数据,默认不超过32条。因此也无法保证消息有序。

RocketMQ 在默认情况下不保证顺序,要保证全局顺序,需要把 Topic 的读写队列数设置为 1,然后生产者和消费者的并发设置也是 1,不能使用多线程。所以这样的话高并发,高吞吐量的功能完全用不上。全局有序就是无论发的是不是同一个分区,我都可以按照你生产的顺序来消费。分区有序就只针对发到同一个分区的消息可以顺序消费。

kafka保证全链路消息顺序消费,需要从发送端开始,将所有有序消息发送到同一个分区,然后用一个消费者去消费,但是这种性能比较低,可以在消费者端接收到消息后将需要保证顺序消费的几条消费发到内存队列(可以搞多个),一个内存队列开启一个线程顺序处理消息。

RabbitMq没有属性设置消息的顺序性,不过我们可以通过拆分为多个queue,每个queue由一个consumer消费。或者一个queue对应一个consumer,然后这个consumer内部用内存队列做排队,然后分发给底层不同的worker来处理,保证消息的顺序性。

消息积压

线上有时因为发送方发送消息速度过快,或者消费方处理消息过慢,可能会导致broker积压大量未消费消息。消息数据格式变动或消费者程序有bug,导致消费者一直消费不成功,也可能导致broker积压大量未消费消息。解决方案可以修改消费端程序,让其将收到的消息快速转发到其他主题,可以设置很多分区,然后再启动多个消费者同时消费新主题的不同分区。可以将这些消费不成功的消息转发到其它队列里去,类似死信队列,后面再慢慢分析死信队列里的消息处理问题。

在RocketMQ官网中,还分析了一个特殊情况,如果RocketMQ原本是采用的普通方式搭建主从架构,而现在想要中途改为使用Dledger高可用集群,这时候如果不想历史消息丢失,就需要先将消息进行对齐,也就是要消费者把所有的消息都消费完,再来切换主从架构。因为Dledger集群会接管RocketMQ原有的CommitLog日志,所以切换主从架构时,如果有消息没有消费完,这些消息是存在旧的CommitLog中的,就无法再进行消费了。这个场景下也是需要尽快的处理掉积压的消息。

延迟队列

消息被发送以后,并不想让消费者立刻获取,而是等待特定的时间后,消费者才能获取这个消息进行消费。例如10分钟,内完成订单支付,支付完成后才会通知下游服务进行进一步的营销补偿。往MQ发一个延迟1分钟的消息,消费到这个消息后去检查订单的支付状态,如果订单已经支付,就往下游发送下单的通知。而如果没有支付,就再发一个延迟1分钟的消息。最终在第10个消息时把订单回收,就不用对全部的订单表进行扫描,而只需要每次处理一个单独的订单消息。这个就是延迟对列的应用场景。

rabbittmq,rocketmq都可以通过设置ttl来设置延迟时间,kafka则是可以在发送延时消息的时候,先把消息按照不同的延迟时间段发送到指定的队列中,比如topic_1s,topic_5s,topic_10s,topic_2h,然后通过定时器进行轮训消费这些topic,查看消息是否到期,如果到期就把这个消息发送到具体业务处理的topic中,队列中消息越靠前的到期时间越早,具体来说就是定时器在一次消费过程中,对消息的发送时间做判断,看下是否延迟到对应时间了,如果到了就转发,如果还没到这一次定时任务就可以提前结束了。

mq设置过期时间,就会有消息失效的情况,如果消息在队列里积压超过指定的过期时间,就会被mq给清理掉,这个时候数据就没了。解决方案也有手动写程序,将丢失的那批数据,一点点地查出来,然后重新插入到 mq 里面去。

消息队列高可用

对于RocketMQ来说可以使用Dledger主从架构来保证消息队列的高可用,这个在上面也有提到过。

然后在说说rabbitmq,它提供了一种叫镜像集群模式,在镜像集群模式下,你创建的 queue,无论元数据还是 queue 里的消息都会存在于多个实例上,就是说,每个 RabbitMQ 节点都有这个 queue 的一个完整镜像,包含 queue 的全部数据的意思。然后每次你写消息到 queue 的时候,都会自动把消息同步到多个实例的 queue 上。

RabbitMQ 有很好的管理控制台,可以在后台新增一个策略,这个策略是镜像集群模式的策略,指定的时候是可以要求数据同步到所有节点的,也可以要求同步到指定数量的节点,再次创建 queue 的时候,应用这个策略,就会自动将数据同步到其他的节点上去了。只不过消息需要同步到所有机器上,导致网络带宽压力和消耗很重。

最后再说说kafka,它是天然的分布式消息队列,在Kafka 0.8 以后,提供了副本机制,一个 topic要求指定partition数量,每个 partition的数据都会同步到其它机器上,形成自己的多个 replica 副本,所有 replica 会选举一个 leader 出来,其他 replica 就是 follower。写的时候,leader 会负责把数据同步到所有 follower 上去。如果某个 broker 宕机了,没事儿,那个 broker上面的 partition 在其他机器上都有副本的,如果这上面有某个 partition 的 leader,那么此时会从 follower 中重新选举一个新的 leader 出来。

框架源码

熟悉SpringBean生命周期、Spring循环依赖、Spring容器启动执行流程、Spring事务底层实现原理、Spring IOC容器加载过程、Spring AOP底层实现原理、Spring的自动装配、Spring Boot自动装配、SpringMVC执行流程、Spring CloudGateway调优、Spring Cloud Alibaba Nacos长轮询机制和服务注册发现、Apache ShardingSphere读写分离一致性解决方案。

🍊 Spring Bean的生命周期

Spring Bean的生命周期决定了一个Bean的整个生命周期,它分为四个阶段:实例化、属性赋值、初始化和销毁。

实例化通过构造器实例化和工厂方法实例化两种方式实现;构造器实例化是指通过Java类的构造函数实例化Bean。在Spring中,构造函数可以是无参构造函数,也可以是有参构造函数。Spring通过利用Java反射机制,调用bean对应类的构造方法进行实例化。在XML文件中,可以使用标签的class属性指定要实例化的Bean类。当容器启动时,容器会根据class属性的全限定类名使用反射机制实例化Bean。在注解方式中,@Component、@Service、@Controller等注解都是用来标注Bean类,容器会根据这些注解的信息进行实例化。工厂方法实例化是指通过Java类的静态方法实例化Bean,实现方式类似于单例模式的实现方式。在Spring中,通过配置工厂方法的返回值和参数,实现Bean的实例化。

实例化前后的InstantiationAwareBeanPostProcessor接口是Spring框架中的一个扩展点,例如SmartInstantiationAwareBeanPostProcessor接口主要作用是在Bean实例化之前,提供对Bean实例化的更细粒度的控制,并提供给实现者对AOP代理和"热插拔"类等功能的支持,通过该接口,我们可以在Bean实例化之前完成对Bean实例化的"拦截",并加入自己的处理逻辑。在Bean实例化之前,这些接口可以用来修改Bean的实例化过程,或者进行Bean类型转换,AOP代理等相关操作;在Bean实例化后,这些接口可以用来修改Bean的属性值、进行Bean的修饰、或者完成其他需要在Bean实例化后执行的操作。它提供了在Bean实例化过程中的多个扩展点,在实际应用中,InstantiationAwareBeanPostProcessor接口可以用于实现很多的扩展功能。例如:AOP技术,可以通过实现该接口来在Bean实例化和属性设置过程中进行代理和增强操作。数据库连接池技术,可以通过实现该接口来在Bean实例化过程中进行数据库连接和释放操作。外部配置文件加载,可以通过实现该接口来在Bean实例化过程中加载外部配置文件,并将其设置到Bean实例中。

属性赋值是Spring Bean生命周期的第二个阶段,它是通过BeanPostProcessor接口实现的。BeanPostProcessor在实例化Bean后,对Bean进行属性赋值。属性赋值可以通过XML文件或注解方式进行配置,在XML文件中,可以使用标签或标签进行属性赋值。Spring容器在实例化Bean后,会遍历所有的BeanPostProcessor实现类,调用它们的postProcessBeforeInitialization()方法,进行属性赋值操作。这个方法的返回值是一个对象,可以修改或替换原始的Bean实例。在注解方式中,可以使用@Autowired或@Value注解进行属性赋值。这些注解的实现原理也是基于BeanPostProcessor接口实现的。

属性注入之后,开始执行Aware,Spring框架提供了Aware相关接口,如BeanNameAware、BeanFactoryAware、ApplicationContextAware等,通过实现 BeanNameAware接口,Bean可以获取到Spring容器中创建的Bean的名称,可以在Bean中直接调用该名称对应的Bean。通过实现BeanFactoryAware接口,Bean可以获取到Spring容器中的BeanFactory,可以通过BeanFactory获取其他Bean的实例。通过实现ApplicationContextAware接口,Bean可以获取到Spring容器的ApplicationContext,可以通过ApplicationContext获取其他Bean的实例和容器中的其他资源。

初始化是Spring Bean生命周期的第三个阶段,它包括两个过程:初始化前和初始化后。Spring提供了一个扩展点BeanPostProcessor,BeanPostProcessor是在Bean的创建过程中,在执行初始化方法之前和之后的扩展点,它定义了两个方法:postProcessBeforeInitialization()和postProcessAfterInitialization()。

postProcessBeforeInitialization()方法在执行Bean的初始化方法之前被调用,可以对Bean进行自定义的初始化操作。例如,可以修改Bean的属性值、增加一些代理逻辑等等。此时,Bean还没有执行初始化方法,也就是说Bean还没有完全初始化。这个方法常常用于注册一些事件监听器、给Bean进行数据校验等。

实现InitializingBean接口是一种在Spring框架中初始化Bean的方式,这种方式要求Bean实现InitializingBean接口,并且实现afterPropertiesSet()方法,在该方法中执行Bean的初始化操作。Spring容器在创建Bean实例之后,会自动调用afterPropertiesSet()方法完成Bean的初始化。除了实现InitializingBean接口,还可以通过@Bean注解中的initMethod属性,或者在XML配置文件中使用元素的init-method属性来指定Bean的初始化方法。

postProcessAfterInitialization()方法在执行Bean的初始化方法之后被调用,可以对Bean进行自定义的后处理操作。例如,可以对Bean做一些额外的检查、修改某些属性值等等。此时,Bean已经执行了初始化方法,并且已经完全初始化。这个方法常常用于增强Bean的能力或者为Bean提供一些额外服务(如数据缓存、资源池等)。

销毁是Spring Bean生命周期的最后一个阶段,它是通过实现DisposableBean接口或通过配置destroy-method方法来实现。实现DisposableBean接口,需要实现destroy()方法,该方法会在Bean销毁前被调用。在容器关闭之前,Spring会先销毁Bean,并回调Bean的destroy()方法。在XML文件中,可以使用destroy-method属性指定Bean的销毁方法。Spring容器会在销毁Bean之前调用这个方法。通过实现DisposableBean接口,Bean类可以在调用destroy()方法之前实现销毁操作。该方法会在Bean销毁之前调用。销毁的具体过程可以自定义实现。在销毁Bean之前,需要先关闭应用上下文,释放Bean占用的资源。

举个例子:假设你是一位追求完美的花艺师。你在春天的花展上,带来了你独特的艺术作品。这次,你决定采用Spring Bean来打造花展中的一个展位。

首先,你需要在花展中找到一个空间。这个空间就相当于Spring容器中的配置文件。你需要在配置文件中声明Bean,并为它们设置属性和依赖关系,这些都是在属性设置阶段完成的。在这个过程中,你会逐步构建出一个原型对象。

接下来,你需要把所有花束放在花架上。这就相当于初始化前阶段,你会在这个阶段进行一些准备工作,比如检查花束的数量、清洁花束、调整花束的位置等。在这个阶段,你也可以使用实现了InitializingBean接口的类,来执行一些自定义的初始化操作。

初始化后阶段就像你在花展前调整花束的最后一次机会。你可以使用实现了BeanPostProcessor接口的类,来拦截初始化过程,对花束进行修改或封装。在一切准备就绪后,你的花束就已经完全初始化了。

展示过程中,你需要不断调整花束的状态和位置,以确保它们始终保持最佳状态。这就相当于Bean的生命周期中的实例化和初始化阶段。在这个过程中,你可以使用反射机制或者Setter方法来注入属性,并使用xml文件中声明的init-method属性来指定初始化方法。

当所有展示结束后,你需要开始收拾花束,并将它们妥善地保存。这就相当于Bean的销毁阶段。在这个阶段,你可以使用实现了DisposableBean接口的类,来执行一些自定义的销毁操作。你也可以使用xml文件中声明的destroy-method属性来指定销毁方法。

总之,Spring Bean的生命周期就像是展示你的花艺作品一样。你需要在花展中找到空间、搭建花架、放置花束、调整花束的位置和状态、收拾花束和花架,这个过程非常重要,对于你的花艺作品和Spring应用的性能都至关重要。

🍊 Spring循环依赖

🎉 什么是循环依赖?

BeanA类依赖了BeanB类,BeanB类依赖了BeanC类,BeanC类依赖了BeanA类,形成了一个依赖闭环,我们把这种依赖关系就称之为循环依赖。

🎉 循环依赖会导致什么问题的出现?

启动项目,我们发现只要是有循环依赖关系的属性并没有自动赋值,而没有循环依赖关系的属性均有自动赋值 ,如下图所示:

IoC容器对Bean的初始化是根据BeanDefinition循环迭代,有一定的顺序。这样,在执行依赖注入时,需要自动赋值的属性对应的对象有可能还没初始化,没有初始化也就没有对应的实例可以注入。

🎉 Spring是怎么解决循环依赖导致的问题的?

Spring使用三级缓存解决循环依赖的过程

  1. 一级缓存存放实例化对象 。
  2. 二级缓存存放已经在内存空间创建好但是还没有给属性赋值的对象。
  3. 三级缓存存放对象工厂,用来创建提前暴露到bean的对象。

代码举例:

@Service
public class TestService1 {

    @Autowired
    private TestService2 testService2;

    public void test1() {
    }
}

@Service
public class TestService2 {

    @Autowired
    private TestService1 testService1;

    public void test2() {
    }
}


testService1先去一级缓存看有没有实例,发现没有,继续去二级缓存查看,发现没有,去三级缓存查看,发现没有实例就创建实例,在创建的过程中,提前暴露,添加到三级缓存里。

这个时候进行属性赋值,发现还有一个testService2,它没有赋值,是一个空的,就从一级缓存中去看testSerivce2有没有实例,发现没有,去二级查看发现没有,去三级缓存查看,发现没有,就创建实例,也提前暴露,添加到三级缓存里面。

这个时候testSerivce2对象里面发现testService1里面没有赋值,然后对testService1进行赋值,从一级缓存去查看,发现没有,去二级查看,发现没有,去三级查看,发现有,就把实例testService1从三级缓存添加到二级缓存里面,把实例testService1三级缓存的实例删除,这个时候,testService2里面有实例对象,对象里面的testService1也有值了,就是一个可以使用的实例对象了,就把这个对象移动到一级缓存里面,把三级缓存里面的testService2删除。

这个时候testService1里面的testService2属性就可以从一级缓存里面获取这个testService2实例了,把它进行赋值填充,testService1也完成了实例化,把testService1从二级缓存移动到一级缓存里面,把testService1在二级缓存的实例也删除。

## 🍊 Spring容器启动流程

Spring容器的启动流程主要分为以下几个步骤:

  1. 加载配置文件:Spring容器会从指定的配置文件中读取配置信息,包括bean的定义、依赖关系、AOP切面等。

  2. 创建容器:Spring容器启动后会创建一个容器实例,容器负责管理bean的生命周期和依赖关系。

  3. 扫描包并创建bean定义:Spring容器会扫描指定的包路径,自动创建包中标注了@Component、@Service、@Controller、@Repository等注解的类的bean定义。

  4. 解析依赖关系:Spring容器会根据bean定义中的依赖关系,自动将依赖的bean注入到需要的bean中。

  5. 初始化bean:容器会按照指定的顺序依次对bean进行初始化,包括实例化、属性注入、初始化方法执行等。

  6. 设置代理对象:如果bean需要被AOP切面增强,则容器会为其创建代理对象。

  7. 完成容器初始化:所有bean初始化完成后,Spring容器启动完成。

在实际开发中,Spring容器的启动可以通过多种方式来实现,包括XML配置和注解配置等。其中XML配置主要通过applicationContext.xml文件来实现,这个配置文件中,告诉Spring容器要创建的bean的名称和类名,这样就可以在Spring容器中实例化这个类,并将其作为一个bean注册到Spring容器中。

除了XML配置之外,注解配置也是一种很常见的配置方式。在Java代码中,我们可以通过在类、字段、方法上添加一些特定的注解来告诉Spring容器如何创建和管理Bean对象。常见的注解包括@Component、@Service、@Controller、@Repository等。

启动Spring容器,首先需要创建一个ApplicationContext对象。这个对象是Spring框架的核心,负责管理所有的Bean对象,以及解决它们之间的依赖关系。ApplicationContext对象可以通过多种方式来创建。一般情况下,我们可以通过ClassPathXmlApplicationContext或AnnotationConfigApplicationContext类来创建一个ApplicationContext对象。其中ClassPathXmlApplicationContext类用于XML配置文件,AnnotationConfigApplicationContext用于注解配置。

一旦配置文件准备好了,就可以开始启动Spring容器了。为了启动容器,需要使用ApplicationContext接口的一个实现类。在这个实现类中,有一个非常重要的方法叫做refresh(),它会触发Spring框架开始加载和初始化所有的Bean对象。

refresh()方法是Spring框架启动过程中的核心方法。首先,refresh()方法会创建一个BeanFactory,这个BeanFactory是一个Bean工厂,是Spring框架中提供的一种对象创建和管理机制。BeanFactory会读取配置文件,通过反射机制实例化对应的Bean,然后将Bean注册到容器中。

接下来,refresh()方法会启动各种后置处理器PostProcessor,后置处理器是一种回调函数,它可以在Bean实例化、初始化之前或之后进行操作,比如修改Bean属性、替换Bean对象等。Spring框架中有很多内置的后置处理器。

比如AutowiredAnnotationBeanPostProcessor用于处理@Autowired和@Inject注解。它会在bean实例化后递归的处理bean的属性,并根据属性上的注解来自动装配依赖。

CommonAnnotationBeanPostProcessor用于处理JSR-250规范的注解,包括@Resource、@PostConstruct和@PreDestroy。

InitDestroyAnnotationBeanPostProcessor用于处理@PostConstruct和@PreDestroy注解,它会在bean的初始化和销毁阶段对相应的方法进行回调。

在执行了后置处理器之后,refresh()方法会执行BeanFactory的预实例化单例Bean,这个过程会通过调用getBean()方法来获取Bean实例。在这个过程中,如果Bean实现了InitializingBean接口,那么Spring容器会调用它的afterPropertiesSet()方法来完成Bean的初始化。如果Bean配置了init-method,那么Spring容器也会调用它指定的初始化方法。

最后,refresh()方法会发布上下文事件,这些事件会被注册到各种事件监听器中,用于监控和管理容器生命周期中的各个阶段。Spring框架中有很多内置的事件,这些事件可以在Spring应用程序上下文中定义的Bean中使用,以便在特定生命周期事件发生时执行特定的代码。例如,可以使用这些事件来处理数据源连接、缓存清除、应用程序状态检查等应用程序行为。

比如ContextRefreshedEvent:当ApplicationContext被初始化或刷新时,该事件被发布。该事件适用于需要在启动时执行某些操作的应用程序。

ContextStartedEvent:该事件表示ApplicationContext已启动,用于在应用程序启动后执行某些操作,例如在spring boot应用程序中启动一个后台线程。

ContextStoppedEvent:当ApplicationContext停止时,该事件被发布。该事件适用于在应用程序停止时执行某些清理操作的应用程序。

ContextClosedEvent:当ApplicationContext关闭时,该事件被发布。该事件适用于在应用程序关闭时执行某些清理操作的应用程序。

🍊 Spring传播机制底层原理

Spring支持以下7种事务传播行为:

  1. PROPAGATION_REQUIRED:如果当前存在事务,则加入该事务;否则新建事务,并在方法执行结束后提交事务。
  2. PROPAGATION_SUPPORTS:如果当前存在事务,则加入该事务;否则不开启事务。
  3. PROPAGATION_MANDATORY:如果当前存在事务,则加入该事务;否则抛出异常。
  4. PROPAGATION_REQUIRES_NEW:不管当前是否存在事务,都新建一个事务,并在方法执行结束后提交事务。
  5. PROPAGATION_NOT_SUPPORTED:不管当前是否存在事务,都不开启事务。
  6. PROPAGATION_NEVER:如果当前存在事务,则抛出异常;否则不开启事务。
  7. PROPAGATION_NESTED:如果当前存在事务,则在已有事务中嵌套一个事务;否则新建事务,并在方法执行结束后提交事务。

Spring的事务传播机制的实现原理:

在Spring框架内部,事务的传播机制是通过ThreadLocal对象来实现的。ThreadLocal是一个线程本地变量,它可以在当前线程中存储某个值,并且这个值可以被当前线程的任何方法所共享和修改。在Spring中,我们可以通过TransactionSynchronizationManager类来管理ThreadLocal变量。

在Spring中,开启事务的方法通常被称为事务模板方法。事务模板方法负责创建事务,并且将当前线程的状态保存在ThreadLocal变量中。在执行业务方法前,Spring事务管理器会检查当前线程的状态,如果当前线程已经存在事务,则直接使用该事务;否则创建一个新事务。

在Spring中,每个事务方法都是由一个或多个拦截器组成的。事务拦截器负责拦截业务方法执行前后的各种事件,并且在恰当的时候执行提交或回滚事务等操作。在Spring中,我们可以通过TransactionInterceptor类来实现事务拦截器。

在Spring的事务传播机制中,每个事务方法都是独立的,它们的事务行为是相互独立的。在事务方法内部调用其他事务方法时,Spring会根据所设置的传播行为来决定是否开启新事务,或者将当前事务合并到已有事务中。

Spring的事务传播机制是基于AOP实现的,它首先在调用业务方法之前,开启事务,并将事务状态保存在ThreadLocal变量中;然后执行业务方法;最后在业务方法执行结束后,根据事务状态来决定是提交还是回滚事务。

Spring的事务管理API主要包括以下三个接口:

PlatformTransactionManager是事务管理器的顶层接口,它定义了使用事务的基本方法,如开启、提交、回滚、暂停、恢复等。所有的事务管理器都需要实现PlatformTransactionManager接口。

TransactionDefinition是事务定义接口,它定义了一个事务的属性,如事务的隔离级别、传播行为、超时时间和只读属性等。所有的事务管理器必须支持TransactionDefinition接口的所有属性。

TransactionStatus是事务状态接口,它定义了事务的当前状态,例如是否已经开始、是否已经提交、是否已经回滚等。所有的事务管理器必须支持TransactionStatus接口的所有状态。

Spring的事务传播机制的实现主要涉及以下几个核心类:

TransactionSynchronizationManager是Spring事务同步管理器,它负责处理同步回调和资源清理,以及管理线程本地变量资源。 在Spring的事务传播机制中,TransactionSynchronizationManager使用ThreadLocal来保存当前线程的事务状态和事务资源。

TransactionAspectSupport是Spring事务切面支持类,它是Spring事务传播机制的核心实现类。TransactionAspectSupport类继承自AspectJAfterAdvice类,实现了org.aopalliance.intercept.MethodInterceptor接口,它可以作为一个通用的事务拦截器来拦截任何一个Spring Bean中的方法调用,并根据所设置的传播行为来决定是否开启新事务,或者将当前事务合并到已有事务中。

AbstractPlatformTransactionManager是PlatformTransactionManager接口的抽象实现类,它提供了大部分的PlatformTransactionManager接口方法的默认实现,具体实现细节由其子类来完成。

AbstractTransactionStatus是TransactionStatus接口的抽象实现类,它提供了大部分的TransactionStatus接口方法的默认实现,具体实现细节由其子类来完成。

🍊 Spring IOC容器加载过程

Spring IoC容器的加载过程主要包括加载配置文件、解析和注册BeanDefinition、初始化BeanDefinition、加载Bean、填充Bean属性、初始化Bean和销毁Bean等步骤。

Spring IOC容器加载过程:

  1. 配置文件读取:Spring IOC容器会读取XML配置文件,通过解析XML文件获取Bean定义信息。

  2. Bean实例化:通过反射机制,根据Bean定义信息创建Bean实例。

  3. Bean属性注入:通过反射机制,将定义在XML文件中的属性值设置给Bean对象。

  4. Bean对象注册:将实例化后的Bean对象注册到Spring IOC容器中,以供后续使用。

  5. Bean生命周期管理:Spring IOC容器会管理Bean对象的整个生命周期,包括Bean的创建、初始化和销毁。

🍊 Spring AOP底层实现原理

Spring AOP底层实现原理主要涉及以下三个方面:

  1. JDK动态代理:JDK动态代理是Spring AOP的默认实现方式。它基于Java反射机制,能够在运行时动态生成代理对象,从而实现对目标对象的方法拦截。Spring AOP使用JDK动态代理实现基于接口的代理,只有实现了接口的类才能被代理。

JDK 动态代理的流程如下:通过 java.lang.reflect.Proxy 类的 newInstance() 方法生成代理对象。在生成代理对象时需传递一个实现了 java.lang.reflect.InvocationHandler 接口的类的实例,即 InvocationHandler 对象。代理对象调用任何方法都会被转发到 InvocationHandler 对象的 invoke() 方法中执行。在 invoke() 方法中,根据方法名和参数类型等信息,使用反射机制调用被代理对象的原始方法。

  1. CGLIB动态代理:CGLIB动态代理是另一种实现AOP的方式,它是基于字节码实现的动态代理,可以代理那些没有实现接口的类。在运行时,CGLIB通过生成目标类的子类来拦截方法调用,从而实现AOP功能。

CGLIB 的流程如下:通过 CGLIB 提供的 Enhancer 类来生成代理对象。在生成代理对象时需传递一个实现了 MethodInterceptor 接口的类的实例,即 Callback 对象。代理对象调用任何方法都会被转发到 Callback 对象中的 intercept() 方法中执行。在 intercept() 方法中,根据方法名和参数类型等信息,使用反射机制调用被代理对象的原始方法。

  1. 代理链的创建:Spring AOP采用代理链来实现方法拦截。在代理链中,每个代理对象拦截目标对象的方法调用,并将请求传递给下一个代理对象,直到目标对象的方法被调用。代理链的创建是通过AOP配置文件或注解进行的。

总体来说,Spring AOP底层实现原理就是在运行时动态生成代理对象,通过代理链实现对目标对象的方法拦截。如果是基于接口的代理,则使用JDK动态代理;如果是基于类的代理,则使用CGLIB动态代理。

🍊 Spring的自动装配

Spring的自动装配是一种自动化的任务分配方式,它能够自动地将应用程序中的各个模块组合在一起,形成完整的应用程序。从面试者的角度来讲,可以从以下几个方面来详细说明Spring的自动装配:

  1. 什么是Spring的自动装配?

Spring的自动装配是一种基于控制反转(IoC)和依赖注入(DI)的实现方式,它能够自动地将应用程序中的Bean装配到各个模块中。当注入的Bean类型匹配时,Spring会自动完成Bean的注入。

  1. Spring自动装配的优势是什么?

Spring的自动装配能够极大地简化开发操作,减少了手动配置Bean的步骤,提高了开发效率。同时,它也能够消除重复代码,增强了模块之间的解耦性,使得应用程序更加易于维护和扩展。

  1. Spring自动装配的方式有哪些?

Spring自动装配有三种方式:默认的基于名称的自动装配、基于类型的自动装配和基于注解的自动装配。

  • 基于名称的自动装配:这种方式是默认的自动装配方式,Spring会自动将属性名与容器中定义的Bean名称进行匹配,如果匹配成功,则将Bean注入到属性中。
  • 基于类型的自动装配:这种方式是根据类型来自动进行装配的,Spring会自动将属性的类型与容器中定义的Bean类型进行匹配,如果匹配成功,则将Bean注入到属性中。
  • 基于注解的自动装配:这种方式是根据注解来进行自动装配的,通过在属性上使用@Autowired和@Qualifier注解,Spring会自动将符合条件的Bean注入到属性中。
  1. Spring的自动装配可能会出现的问题有哪些?

Spring的自动装配虽然能够提高开发效率,但在实际开发中也可能会遇到一些问题,比如:

  • 自动装配的Bean可能会出现多个候选者的情况;
  • 自动装配可能会造成Bean之间的循环依赖问题;
  • 自动装配可能会导致开发者对Bean的装配过程不够清晰,降低了代码的可读性。
  1. Spring使用以下规则来决定需要自动装配的Bean

    默认情况下,Spring会尝试按照名称进行自动装配。这意味着Spring会查找与依赖属性名称相同的Bean名称,并将Bean自动注入到属性中。

    如果按照名称进行自动装配失败,Spring会尝试按照类型进行自动装配。这意味着Spring会查找与依赖属性类型相同的Bean,并将Bean自动注入到属性中。

    如果按照类型进行自动装配失败,Spring会尝试使用构造函数进行自动装配。这意味着Spring会查找与构造函数参数类型相同的Bean,并将Bean作为参数自动注入到构造函数中。

如果以上三种规则均无法完成自动装配,则Spring会抛出异常。

需要注意的是,Spring在进行自动装配时会优先使用已经被标记为Primary的Bean,如果没有找到Primary Bean,才会使用其他的进行自动装配。

总的来说,Spring的自动装配是一种方便而快捷的开发方式,可以大大提高开发效率,但在实际使用中还是需要谨慎使用,避免出现不必要的问题。

🍊 Spring6.0核心新特性

作为面试者,要讲清楚Spring6.0的核心新特性可以从以下几个方面展开:

  1. Spring6.0于2021年9月发布,Spring 6.0的核心新特性包括:支持Java 17和Java 18、将应用程序编译成原生镜像以支持云原生环境、引入AOT编译基础、改进Spring Boot自动配置、更新Spring Data以支持最新持久性框架、更新Spring WebSocket以支持最新WebSocket协议、改进Spring Security以支持OAuth 2.0和最新安全标准,以及改进测试模块以支持JUnit 5和其他测试框架。此外,还提供了对最新Web容器和持久性框架的访问。

  2. 强调Spring6.0在模块化和打包方面的改进。采用更细粒度的模块化设计,每个模块更加独立和可替换。同时引入新的包布局,使依赖关系更清晰,便于开发和维护。

  3. 提及Spring6.0在性能方面的优化措施。减少内存占用、提高启动速度和运行效率,使应用程序能更好地处理高并发和大数据量的场景。

  4. 强调Spring6.0与Project Reactor的集成,为响应式编程提供更好的支持。使得开发响应式应用更加简单和高效。

  5. 指出Spring6.0在安全性方面的改进。支持最新的安全标准如TLS 1.3和HTTP/2,提供更强的密码学支持,增强应用程序的安全性。

  6. 强调Spring6.0在测试方面的简化。引入新的测试模块,使测试编写更加容易和直观。同时与主流测试框架如JUnit和Mockito等更好集成。

  7. 强调Spring6.0将提供长期支持,确保应用程序的稳定性和可靠性。让开发人员放心使用Spring6.0进行开发。

🍊 Spring Boot自动装配

Spring Boot启动的时候会通过@EnableAutoConfiguration注解找到META-INF/spring.factories配置文件中的所有自动配置类,并对其进行加载,而这些自动配置类都是以AutoConfiguration结尾来命名的,它实际上就是一个JavaConfig形式的Spring容器配置类,它能通过以Properties结尾命名的类中取得在全局配置文件中配置的属性如:server.port,而XxxxProperties类是通过@ConfigurationProperties注解与全局配置文件中对应的属性进行绑定的。

启动类的@SpringBootApplication注解由@SpringBootConfiguration,@EnableAutoConfiguration,@ComponentScan三个注解组成,三个注解共同完成自动装配;

@SpringBootConfiguration 注解标记启动类为配置类

@ComponentScan 注解实现启动时扫描启动类所在的包以及子包下所有标记为bean的类由IOC容器注册为bean

@EnableAutoConfiguration通过 @Import 注解导入 AutoConfigurationImportSelector类,然后通过AutoConfigurationImportSelector 类的 selectImports 方法去读取需要被自动装配的组件依赖下的spring.factories文件配置的组件的类全名,并按照一定的规则过滤掉不符合要求的组件的类全名,将剩余读取到的各个组件的类全名集合返回给IOC容器并将这些组件注册为bean

Spring Boot自动装配的底层实现原理主要依赖于Spring Framework的核心组件:IoC容器。Spring Boot在启动时会自动扫描项目中的所有类,并通过IoC容器将这些类进行实例化和注入。

具体来说,Spring Boot通过以下几个步骤实现自动装配:

  1. 扫描类路径下的所有类:在启动时,Spring Boot会自动扫描类路径下的所有类(包括jar包中的类),并将这些类进行解析和加载。

  2. 根据条件自动装配:Spring Boot通过条件注解(如@ConditionalOnClass、@ConditionalOnBean等)来判断哪些类需要被自动装配。在满足条件的情况下,Spring Boot会自动创建该类的实例,并将其注入到IoC容器中。

  3. 自动配置:除了通过条件注解判断哪些类需要被自动装配外,Spring Boot还提供了一系列自动配置类,用于自动配置各种常用的组件(如数据库连接池、Web容器等)。当自动装配某个组件时,Spring Boot会先检查该组件是否已经存在,如果不存在,则自动创建该组件的实例并注入到IoC容器中。

  4. 处理自定义配置:Spring Boot还支持外部化配置,可以通过properties文件或YAML文件来配置应用程序。在启动时,Spring Boot会读取这些配置文件,并将其注入到IoC容器中,以便在应用程序中进行使用。

总的来说,Spring Boot的自动装配机制主要依赖于IoC容器和条件注解,通过自动扫描类路径、判断条件和自动配置组件来实现自动装配。这种机制可以大大简化应用程序的配置工作,提高开发效率。

🍊 Spring Framework的SPI机制

介绍SPI机制:SPI机制是Java平台提供的一种服务发现机制,被广泛用于实现框架扩展。SPI机制将接口实现类的全限定名配置在文件中,并由服务加载器读取配置文件,加载实现类。Spring Framework将SPI机制用于实现可扩展性,使得第三方插件可以与Spring核心框架无缝集成。

SPI机制的实现方式:Spring Framework通过org.springframework.core.io.support.SpringFactoriesLoader类来实现SPI机制。该类会在META-INF/spring.factories文件中查找实现类的全限定名,并实例化对应的对象。第三方插件只需要在META-INF/spring.factories文件中声明自己的实现类,就可以被Spring Framework自动加载和集成。

SPI机制的优点:SPI机制为很多框架扩展提供了可能,使得Spring Framework具有很高的可扩展性。开发者可以通过实现Spring Framework提供的扩展点接口,将插件集成到Spring应用程序中。此外,SPI机制还使得代码更加模块化,降低了代码的耦合度,提高了代码的可维护性和可重用性。

SPI机制在Spring中的应用:SPI机制在Spring中广泛应用于各个模块,如JDBC、Web、AOP等。例如,在JDBC模块中,Spring通过SPI机制加载不同类型的数据库驱动;在Web模块中,Spring通过SPI机制加载不同类型的Web容器(如Tomcat、Jetty等)。

注意事项和限制:虽然SPI机制提供了很好的扩展性,但也存在一些注意事项和限制。首先,需要确保SPI机制所需的文件(如META-INF/spring.factories)正确配置;其次,需要避免实现类之间的冲突;最后,需要考虑性能开销,因为SPI机制需要加载和初始化实现类。

🍊 Spring Boot启动过程

Spring Boot的启动过程是一个逐步初始化的过程,主要包括以下几个步骤:

  1. 创建SpringApplication对象:在启动过程中,首先会创建一个SpringApplication对象。该对象负责管理整个Spring Boot应用的生命周期。

  2. 加载配置文件:Spring Boot会自动加载应用程序的配置文件,例如application.properties或application.yml。这些配置文件包含了各种配置项,如服务器端口、数据库连接等。

  3. 创建并配置EmbeddedServletContainerFactory:EmbeddedServletContainerFactory是Spring Boot中用于创建内嵌的Servlet容器的工厂类。它会读取配置文件中的相关信息,如嵌入式服务器类型、上下文路径等,并根据这些信息创建一个内嵌的Servlet容器。

  4. 注册监听器和事件处理器:在创建EmbeddedServletContainerFactory之后,Spring Boot会注册一些默认的监听器和事件处理器。这些监听器和事件处理器可以处理应用程序的生命周期事件,如应用程序启动、停止等。

  5. 准备环境:在完成以上步骤之后,Spring Boot会进行一些准备工作,包括加载应用程序的基础依赖、配置环境变量等。这样可以使应用程序在一个合适的环境中运行。

  6. 启动嵌入式Servlet容器:准备工作完成后,Spring Boot会调用EmbeddedServletContainerFactory的start()方法来启动内嵌的Servlet容器。这个容器负责处理HTTP请求和响应。

最后,通过以上步骤,Spring Boot应用程序会成功启动并运行起来。在整个启动过程中,Spring Boot会自动管理各种组件和依赖关系,使得开发者可以更加专注于业务逻辑的开发,而不需要过多关注底层的细节。

🍊 SpringMVC执行流程

项目经验

项目技术

以高并发、高性能、高可用的技术作为基础保障,重点突破快速扩容,秒级启动,弹性伸缩,高可靠,核心的技术内容:

缓存设计:多级缓存与库存分割

分离策略:主从分离与动静分离

数据一致:数据库与缓存一致、主库与从库一致

数据兜底:内存快照、数据库备份、数据冗余、多活架构、分布式事务、数据高可用

流量策略:负载均衡与加权处理

数据库优化:合理选择字段类型、索引设计、查询性能、分库分表(垂直拆分与水平拆分)

异步优化:系统异步化、缓存队列、缓冲队列

代码异步化处理:Servlet3异步化、使用MQ异步化、自定义异步化策略

使用多级缓存:本地缓存、Redis缓存、数据库缓存

缓存问题:缓存击穿、缓存穿透、缓存雪崩

合理使用锁:注意锁粒度、锁的获取与释放、锁超时

池化技术:线程池、连接池、对象池、缓冲区

SQL优化:尽量走索引、尽量减少关联查询、查询数据量尽量少

物理机极致优化:CPU模式优化、操作系统参数优化、套接字缓冲区优化、频繁接收大文件优化、网卡层面优化、TCP连接优化、Nginx优化、网关优化

单机Java进程极致优化:JVM优化、Tomcat优化、线程模型优化、Servlet3异步化、RPC框架调优、资源静态化、Vertx异步化

隔离策略:线程隔离、连接隔离、业务隔离、系统隔离、数据隔离、热点隔离(动态与静态)流量隔离、逻辑隔离、物理隔离

流量控制:预约设计、缓存设计、动态感知、把控参与人数、设置人数上限

削峰与限流:验证码、问答题、异步消息、分层过滤、服务网关限流、业务网关限流、应用层限流(线程池限流与API限流)

服务降级:读服务降级、写服务降级、简化系统功能、舍弃非核心功能、数据兜底

热点数据:读热点与写热点

服务容灾:同机房多部署、多机房部署、同城双活、异地多活

库存扣减设计:下单减库存、付款减库存、与扣减库存、库存扣减问题解决方案、秒杀系统扣减库存方案、Redis实现扣减库存、Redis+Lua解决超卖、Redis分割库存、商品维度限购、库存防超卖

限购规则:商品维度限购与个人维度限购

防刷策略:Nginx条件限流、Token机制防刷、布隆过滤器校验、黑名单机制

风控策略:完善用户画像、丰富业务场景、不断优化算法

项目管理

能独立或带领团队Java工程师成员完成服务端代码的研发工作,结合业务需求给出合理的技术解决方案,改进现有模块功能,提高系统的可扩展性,封装性,稳定性。深入挖掘业务需求,可0-1设计高可用、高并发、高伸缩的分布式项目架构,环境搭建、自动化部署、服务器环境线上排查、性能评估相关经验。拥有产品需求讨论、项目开发计划制定、控制项目风险、开发团队组建、技术小组日常管理、进度检验、成本管理、开发部署问题梳理、任务分配、负责指导、培训普通开发工程师、代码Review、审核开发工程师的设计与研发质量等经验。

相关推荐
IT女孩儿8 分钟前
JavaScript--WebAPI查缺补漏(二)
开发语言·前端·javascript·html·ecmascript
m0_748238929 分钟前
webgis入门实战案例——智慧校园
开发语言·ios·swift
醒了就刷牙14 分钟前
黑马Java面试教程_P9_MySQL
java·mysql·面试
m0_7482336421 分钟前
SQL数组常用函数记录(Map篇)
java·数据库·sql
Clockwiseee23 分钟前
PHP伪协议总结
android·开发语言·php
小灰灰搞电子24 分钟前
Qt实现Android的图案密码(图形解锁)源码分享
开发语言·qt
编程爱好者熊浪1 小时前
JAVA HTTP压缩数据
java
吴冰_hogan1 小时前
JVM(Java虚拟机)的组成部分详解
java·开发语言·jvm
白宇横流学长2 小时前
基于java出租车计价器设计与实现【源码+文档+部署讲解】
java·开发语言
黑客老陈3 小时前
面试经验分享 | 北京渗透测试岗位
运维·服务器·经验分享·安全·web安全·面试·职场和发展