【Java高级开发高频面试题】面试者角度的口述版

文章目录

1.具备扎实的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对象之后,就会撤销为无锁状态。

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------它的全称是AbstractQueuedSynchronizer,中文意思是抽象队列同步器,它是在java.util.concurrent.locks包下,也就是JUC并发包。在Java中,我们有synchronized关键字内置锁和显示锁,而大部分的显示锁都用到了AQS。例如,只有一个线程能执行ReentrantLock独占锁,又比如多个线程可以同时执行共享锁Semaphore、CountDownLatch、ReadWriteLock、CyclicBarrier。AQS自身没有实现任何同步接口,仅仅是定义了同步状态获取和释放的方法,并提供自定义同步组件使用。子类通过继承AQS,实现该同步器的抽象方法来管理同步状态。使用模板方法模式,在自定义同步组件里调用它的模板方法。这些模板方法会调用使用者重写的方法,这是模板方法模式的一个经典运用。AQS依赖于内部的一个FIFO双向同步队列来完成同步状态的管理。如果当前线程获取同步状态失败,同步器会将当前线程信息构造为一个节点,并将其加入同步队列,同时会阻塞当前线程。当同步状态释放时,首节点中的线程将会被唤醒,使其再次尝试获取同步状态。同步器拥有首节点和尾节点,首节点是获取同步状态成功的节点,首节点的线程在释放同步状态时将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置为首节点。没有成功获取同步状态的线程会成为节点,加入该队列的尾部。

让我们以ReentrantLock为例,线程调用ReentrantLock的lock()方法进行加锁。这个过程中会使用CAS将state值从0变为1。一旦线程加锁成功,就可以设置当前加锁线程是自己。ReentrantLock通过多次执行lock()加锁和unlock()释放锁,对一个锁加多次,从而实现可重入锁。当state=1时代表当前对象锁已经被占用,其他线程来加锁时则会失败。再看加锁线程的变量里面是否为自己。如果不是就说明有其他线程占用了这个锁,失败的线程被放入一个等待队列中,并等待唤醒的时候,经常会使用自旋的方式,不停地尝试获取锁,等待已经获得锁的线程释放锁才能被唤醒。当它释放锁的时候,将AQS内的state变量的值减1,如果state值为0,就彻底释放锁,会将"加锁线程"变量设置为null。这时,会从等待队列的队头唤醒其他线程重新尝试加锁,获得锁成功之后,会把"加锁线程"设置为线程自己,同时线程自己就从等待队列出队。

底层实现独占锁的代码中,首先会调用自定义同步器实现的tryAcquire方法,保证线程安全的获取同步状态。如果获取成功,则直接退出返回;如果获取失败,则构造同步节点,通过addWaiter方法将该节点加入到同步队列的尾部。最后调用acquireQueued方法,让节点自旋获取同步状态。在Java 5之前,如果一个线程在synchronized之外获取不到锁而被阻塞,即使对该线程进行中断操作,中断标志位会被修改,但线程依旧会阻塞在synchronized上,等待着获取锁。而在Java 5中,等待获取同步状态时,如果当前线程被中断,会立即返回,并抛出InterruptedException。后续的版本又提供了超时获取同步状态的方法,支持响应中断,也是获取同步状态的"增强版"。其中,doAcquireNanos方法在支持响应中断的基础上,增加了超时获取的特性。

对于超时获取,需要计算出需要睡眠的时间间隔nanosTimeout。为了防止过早通知,nanosTimeout的计算公式为:nanosTimeout = now - lastTime,其中now为当前唤醒时间,lastTime为上次唤醒时间。如果nanosTimeout大于0,表示超时时间未到,需要继续睡眠nanosTimeout纳秒;否则,表示已经超时。如果nanosTimeout小于等于1000纳秒时,将不会使该线程进行超时等待,而是进入快速的自旋过程。这是因为非常短的超时等待无法做到十分精确,如果此时再进行超时等待,反而会让nanosTimeout的超时从整体上表现得不精确。因此,在超时非常短的场景下,同步器会无条件进入快速自旋。

共享锁是一种同步机制,不同于独占锁,可以允许多个线程同时访问临界区。举个例子,如果我们需要5个子线程并行执行一个任务,可以使用CountDownLatch来实现。我们初始化一个state为5的CountDownLatch,每个子线程执行完任务后调用countDown()方法,state就会减1。当state变为0时,主调用线程从await()函数返回,继续后续动作。在调用同步器的acquireShared方法时,通过tryAcquireShared方法来判断是否能够获取到同步状态。如果可以,就可以进入临界区。需要保证tryReleaseShared方法能够安全释放同步状态。通常会使用循环和CAS来保证线程安全。因为同一时间可以有多个线程获取到同步状态,所以需要使用双向链表来记录等待线程。双向链表有两个指针,可以支持O(1)时间复杂度的前驱结点查找,插入和删除操作也更高效。此外,为了避免链表中存在异常线程导致无法唤醒后续线程的问题,阻塞等待的前提是当前线程所在节点的前置节点是正常状态。如果被中断的线程的状态被修改为CANCELLED,需要从链表中移除,否则会导致锁唤醒的操作和遍历操作之间的竞争。如果使用单向链表,实现起来会非常复杂。加入到链表中的节点在尝试竞争锁之前,需要判断前置节点是否是头节点,如果不是,就不需要竞争锁。

线程池

线程池,简单来说就是对运行线程数量的控制,它通过将任务放到队列中来进行处理,然后在线程创建后启动这些任务,如果线程数量超过了最大数量,那么就会排队等候,等待其他线程先执行完毕,再从队列中取出任务去执行。就像银行网点一样,线程池中的常驻核心数相当于今日当值窗口,线程池能够同时执行的最大线程数相当于银行所有的窗口,任务队列相当于银行的候客区。当同时需要执行的任务数量超过了最大线程数,线程池会将多余的任务放到等待区(相当于候客区),当等待区满的时候,就会按照一定的策略进行拒绝。

当底层创建线程池的时候,有七个核心参数,分别是:核心线程数、同时执行的最大线程数、多余线程存活时间、单位时间秒、任务队列、默认线程工厂以及拒绝策略。其中,最大线程数就是指同时能够执行的最大线程数量,多余线程存活时间指的是当前线程池数量超过核心线程数时,当前空闲时间达到多余线程存活时间的值的时候,多余空闲线程会被销毁到只剩核心线程数为止。任务队列则是被提交但尚未被执行的任务。同时,为了应对不同的需求,线程工厂可以为不同类型的线程提供不同的创建方式。拒绝策略则是用来保证性能和稳定性,当队列满了并且工作线程数量大于线程池的最大线程数时,提供拒绝策略,以便及时应对各种意外情况。

针对CPU密集型任务的特性,我们需要考虑线程池中核心线程数量的设定,如果线程池中核心线程数量过多,会增加上下文切换的次数,带来额外的开销。因此我们需要确保有足够的线程数量去处理任务,以充分利用CPU运算能力,而不浪费CPU时间在上下文切换上。一般情况下,我们建议线程池的核心线程数量等于CPU核心数+1。对于I/O密集型任务,由于CPU使用率并不是很高,可以让CPU在等待I/O操作的时去处理别的任务,从而充分利用CPU。因此线程池中的核心线程数量也需要根据任务类型来进行设定。一般情况下,建议线程的核心线程数等于2*CPU核心数。对于混合型任务,我们需要根据任务类型和线程等待时间与CPU时间的比例来设定线程池的核心线程数量。在某些特定的情况下,还可以将任务分为I/O密集型任务和CPU密集型任务,分别让不同的线程池去处理。一般情况下,线程池的核心线程数应该等于(线程等待时间/线程CPU时间+1)*CPU核心数。打个比方,就像我们写作业或者工作时,需要根据任务类型和资源利用率来设定工作方式,我们需要在不同的任务之间切换来达到更高的效率。如果我们一味地等待一个任务完成,而不去做其他的任务,那么效率就会非常低下。因此线程池的设计也需要根据任务类型和特性来进行规划和优化。

在讨论拒绝策略时,有几种不同的策略可以选择。首先,第一种拒绝策略是AbortPolicy。当线程池中的线程数达到最大值时,系统将直接抛出一个RejectedExecutionException异常,从而阻止系统的正常运行。通过感知到任务被拒绝,我们可以根据业务逻辑选择重试或者放弃提交等策略。第二种拒绝策略,该策略不会抛弃任务,也不会抛出异常。相反,它会将某些任务回退给调用者。当线程池无法处理当前任务时,将执行任务的责任交还给提交任务的线程。这样,提交的任务不会丢失,从而避免了业务损失。如果任务耗时较长,提交任务的线程在此期间也会处于忙碌状态,无法继续提交任务。这相当于一个负反馈,有助于线程池中的线程消化任务。第三种拒绝策略是DiscardOldestPolicy。当任务提交时,如果线程池中的线程数已经达到最大值,它将丢弃队列中等待最久的任务,并将当前任务加入队列中尝试再次提交。第四种拒绝策略是DiscardPolicy。与前三种策略不同,DiscardPolicy直接丢弃任务,不对其进行处理,也不会抛出异常。当任务提交时,它直接将刚提交的任务丢弃,而且不会给出任何提示通知。总的来说,这四种拒绝策略各有优缺点,具体选择哪种策略取决于实际业务需求和场景。

在Java中,java.util.concurrent包提供的Executors来创建线程池。它提供了三种常用的线程池类型:第一种是newSingleThreadExecutors,它是单线程线程池,适用于只有一个任务的场景。第二种是newFixedThreadPool(int nThreads),它是固定大小线程池,适用于任务数已知的场景。第三种是newCachedThreadPool(),它是无界线程池,适用于任务数不确定的场景,但是这种线程池的队列相当于没有限制,可能会出现OOM的问题。我建议在实际应用中不要使用JDK提供的三种常见创建方式,因为这些方式使用场景很有限,而且底层都是通过ThreadPoolExecutor创建的线程池。相比之下,直接使用ThreadPoolExecutor创建线程池更容易理解原理,也更加灵活。此外,阿里巴巴开发手册也推荐使用ThreadPoolExecutor去创建线程池,因为它可以灵活地控制任务队列的大小,避免了OOM等问题的出现。

JVM内存模型

在JDK1中,JVM只有堆内存和方法区两个部分。其中,堆内存负责存储对象实例,方法区则负责存储类信息、常量池、方法描述等。在JDK1中,没有虚拟机栈、本地方法栈和程序计数器等部分,因此对于异常处理和线程同步等方面,只能通过操作系统提供的方式实现。

在JDK2中,JVM新增了虚拟机栈和程序计数器两个部分。虚拟机栈用于存储每个线程的方法调用栈,程序计数器则记录每个线程当前执行的字节码指令位置。在JDK2中,还没有本地方法栈。

在JDK3中,JVM新增了本地方法栈。本地方法栈和虚拟机栈类似,只不过它是为本地方法服务的,用于支持JVM调用本地方法的机制。JDK3的内存模型中,JVM共有堆内存、方法区、虚拟机栈、本地方法栈和程序计数器五个部分。

在JDK4中,JVM对内存模型进行了大幅度优化。其中,JVM实现了分代垃圾回收,即将堆内存分为新生代和老年代两部分。新生代中又分为Eden区和两个Survivor区。在JDK4中,方法区仍然存在,但用了称为"永久代"的概念。它用于存储类信息、方法描述、常量池等数据,并将它们缓存起来,以便在JVM运行时进行访问。

在JDK5中,JVM对内存模型进行了一些小改进。其中,引入了泛型和自动装箱/拆箱等新特性,这些特性需要JVM在处理对象时进行额外的内存操作。为此,JVM引入了TLAB(线程本地分配缓冲区)机制,用于加速对象的分配过程。

在JDK6中,JVM对内存模型进行了一些优化和改进。其中,引入了"永久代"的概念,来替代原有的方法区。永久代可以动态调整大小,以适应JVM的内存需求。此外,JVM还优化了GC算法,加快了垃圾回收的速度。

在JDK7中,JVM主要修改了内存分配器和垃圾回收器。其中,引入了G1(Garbage First)垃圾回收器,用于处理大内存和高并发的场景。G1垃圾回收器将堆内存分为若干个区域,每个区域都可以独立进行垃圾回收。

在JDK8中,JVM主要改进了垃圾回收器。其中,改进了永久代的存储结构,将永久代替换成了元空间,使得元空间可以根据需要动态地调整大小。此外,JVM还引入了新的垃圾回收器,如CMS(Concurrent Mark-Sweep)和ZGC(Z Garbage Collector),用于提高JVM的性能和稳定性。

在JDK11中,JVM进一步优化了内存分配器和垃圾回收器。其中,引入了Epsilon垃圾回收器,该回收器不对内存进行垃圾回收,而是保留所有对象,直到内存用尽为止。另外,JVM还引入了ZGC的并发模式,提升了JVM在高并发场景下的性能表现。

在JDK17中,JVM主要优化了元空间的性能和稳定性。特别是针对大型应用程序,元空间的性能得到了显著提升。此外,JVM还引入了新的垃圾回收器,如Flight Recorder和Shenandoah,用于提升JVM的性能和稳定性。

类加载机制与双亲委派

首先,当我们编译Java源文件后,就会生成一个class字节码文件存储在磁盘上。接着,JVM会读取这个字节码文件,使用IO流进行读取,这个过程就是加载。加载是由类加载器完成的,它会检查当前类是不是由自定义加载类加载的,如果不是,就委派应用类加载器加载。如果这个类已经被加载过了,就不需要再次加载。如果没有被加载过,就会委派父加载器调用loadClass方法来加载。如果父加载器加载不了,就会一直向上查询,直到启动类加载器。如果所有的加载器都不能加载这个类,就会抛出ClassNotFoundException异常,这就是所谓的双亲委派机制。这种机制可以避免同路径下同文件名的类的冲突。比如,自己写了一个java.lang.obejct,这个类和jdk里面的object路径相同,文件名也一样,这个时候,如果不使用双亲委派机制的话,就会出现不知道使用哪个类的情况,而使用了双亲委派机制,它就委派给父类加载器就找这个文件是不是被加载过,从而避免了上面这种情况的发生。

接下来是验证阶段。JVM会校验加载进来的字节码文件是不是符合JVM规范。首先,会进行文件格式验证,即验证class文件里的魔数和主次版本号,发现它是一个jvm可以支持的class文件并且它的主次版本号符合兼容性要求,所以验证通过。如果符合要求,就进行元数据验证,对字节码描述的信息进行语义分析,比如判断是否有父类、是否实现了父类的抽象方法、是否重写了父类的final方法等。然后是字节码验证,通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。最后是符号引用验证,确保解析动作可以正确执行,比如能否找到对应的类和方法,以及符号引用中类、属性、方法的访问性是否能被当前类访问等。

在完成验证后,我们进入了准备阶段,这时需要为类的静态变量分配内存并赋予默认值。比如说,如果我们有一个public static int a = 12;的变量,我们需要给它分配默认值0。同理,对于一个public static User user = new User();的变量,我们需要为静态变量User分配内存并赋予默认值null。但如果这个变量是用final修饰的常量,那么就不需要再分配默认值,直接赋值就可以了。接下来是解析,就是将符号引用变为直接引用。这个过程会将静态方法替换为指向数据储存在内存中的指针或者句柄,也就是所谓的直接引用。这个过程是在初始化之前完成的。最后是初始化阶段,类的静态变量被初始化为指定的值,并且会执行静态代码块。比如说,在准备阶段,我们的public static final int a = 12;变量会被赋上默认值0,而在初始化阶段,我们需要把它赋值为12。同样地,我们的public static User user = new User();这个变量需要在初始化阶段进行实例化。

最后,就是使用和卸载阶段。至此,整个加载流程就走完了。

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

垃圾回收器有很多,其中新生代的有三种,分别是Serial、ParNew和Parallel Scavenge。Serial采用的是复制算法,是单线程运行的,没有线程交互开销,专注于垃圾回收。但是由于会冻结所有应用线程,且只能在单核cpu下工作,因此一般不使用。ParNew也是采用复制算法,但是支持多线程并行gc,相比Serial,除了多核cpu并行gc以外,其他基本相同。Parallel Scavenge也是采用复制算法,但是它能够进行吞吐量控制的多线程回收,主要关注吞吐量,可以通过设置吞吐量来控制停顿时间,适用于不同的场景。

新生代的垃圾回收器都使用复制算法进行gc。按照分代收集算法的思想,堆空间被分为年轻代、老年代和永久代。其中年轻代又被分为Eden区和两个Survivor存活区,比例为8:1:1。进行gc时,对象会先被分配在Eden区,然后进行minor gc。在新生代中,每次gc都需要回收大部分对象,因此为了避免内存碎片化的缺陷,采用复制算法按内存容量将内存划分为大小相等的两块,每次只使用其中一块,在minor gc期间,存活的对象会被复制到其中一个Survivor区,Eden区继续放对象,直到触发gc。此时,Eden区和存放对象的Survivor区一起gc,存活下来的对象会被复制到另一个空的Survivor区,两个Survivor区角色互换。

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

第二种情况是创建了一个很大的对象,这个对象的大小超过了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则表示不允许担保失败。如果允许,就会检查老年代最大可用连续空间是不是大于历次晋升到老年代平均对象大小,如果大于就尝试一次有风险的minor GC,如果小于或者不允许担保失败,那就直接进行full GC了。

举个例子,在minor GC发生之前,年轻代里面有1GB的对象,这个时候,老年代瑟瑟发抖,JVM为了安慰这个老年代,它在minor GC之前,检查一下老年代最大可用连续空间,假设老年代最大可用连续空间是2GB,JVM就会拍拍老年代的肩膀说,放心,哪怕年轻代里面这1GB的对象全部给你,你也吃得下,你的空间非常充足,这个时候,老年代就放心了。但是大部分情况下,在minor GC发生之前,JVM检查完老年代最大可用连续空间以后,发现只有500MB,这个时候虚拟机不会直接告诉老年代你的空间不够,这个时候会进行第二次检查,检查自己的一个参数handlepromotionfailure的值是不是允许担保失败,如果允许担保失败,就进行第三次检查。检查老年代最大可用连续空间是不是大于历次晋升到老年代平均对象大小,假设历次晋升到老年代平均对象大小是300MB,现在老年代最大可用连续空间只有500MB,很明显是大于的,那么它会进行一次有风险的minor GC,如果GC之后还是大于500MB,那么就会引发full GC了,但是根据以往的一些经验,问题不大,这就是允许担保失败。假设历次晋升到老年代平均对象大小是700MB,现在老年代最大可用连续空间只有500MB,很明显是小于的,minor GC风险太大,这个时候就直接进行full GC了,这就是我们所说的空间分配担保。

老年代使用的垃圾回收器有Serial Old和Parallel Old,采用的是标记整理算法。

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

Serial Old是单线程运行的垃圾回收器,而Parallel Old是可以进行吞吐量控制的多线程回收器,在JDK1.6开始提供,可以保证新生代的吞吐量优先,无法保证整体的吞吐量。

CMS是老年代使用标记清除算法,标记清除算法分为两个阶段,标注和清除。标记阶段标记出所有需要回收的对象,清除阶段回收被标记的对象所占用的空间。CMS是并发收集低停顿的多线程垃圾回收器。它使用的是4个阶段的工作机制,分别是初始标记、并发标记、重新标记和并发清除。并发标记和并发清除过程中,垃圾收集线程可以和用户线程一起并发工作,因此CMS收集器的内存回收和用户线程可以一起并发地执行,但它无法处理浮动垃圾,容易产生大量的内存碎片。

G1收集器将堆内存划分为若干个独立区域,每个区域分为Eden区、Survivor区和大对象区。采用的是标记整理算法,能够非常精确地控制停顿时间,在不牺牲吞吐量前提下,实现低停顿垃圾回收。它能避免全区域垃圾收集,保证在有限时间内获得最高的垃圾收集效率。在jdk1.9中,G1成为默认的垃圾回收器。

引用计数器算法、可达性分析、强软弱虚引用、GC的过程、三色标记、跨代引用

引用计数器算法、可达性分析

在JVM中,所有的对象都存在一个对象头。对象头包括了对象的类型信息、对象的状态信息和对象的引用信息。在对象的引用信息中,有一个重要的字段是"引用计数器",它记录了该对象被引用的次数。当该对象被引用时,计数器增加1;当该对象不被引用时,计数器减少1。当计数器的值为0时,该对象就可以被垃圾回收了。

但是,引用计数器算法存在一个问题,就是无法解决循环引用的问题。如果两个对象相互引用,它们的引用计数器的值始终不为0,就无法进行垃圾回收。因此,JVM采用了可达性分析算法。

如果一个对象已经不再被任何其他对象引用,那么该对象就是不可达的,即它不再被程序使用,可以被回收。在 JVM 中,可达性分析是通过根对象来判断对象是否可达的,比如:当前正在执行的方法中的局部变量和输入参数,线程栈中的对象,静态对象等。判断一个对象是否可达,首先从根对象开始对所有引用进行遍历,找到所有被引用的对象。将这些被引用的对象标记为活动对象,其它对象则被标记为垃圾对象。从活动对象开始对所有引用进行遍历,找到所有被引用的对象,将这些被引用的对象标记为活动对象,其它对象则被标记为垃圾对象。这个过程一直进行下去,直到没有对象可遍历,所有被遍历的非垃圾对象都被标记为活动对象,其它对象都被标记为垃圾对象。

JVM 对不可达对象的处理一般是通过垃圾回收机制来完成的。当 JVM 发现某个对象不再被任何根对象引用时,该对象就变成了不可达对象,这个对象会被标记为垃圾对象。垃圾回收器会在 JVM 空闲时根据特定算法对这些垃圾对象进行回收,回收的过程包括两个阶段:标记和清除。标记阶段:从根对象开始向下遍历所有引用,标记所有被引用的对象,其它对象则被标记为垃圾对象。清除阶段:清除所有被标记为垃圾对象的内存空间,回收这些空间。

强软弱虚引用

JVM中强软弱虚引用是Java中内存管理的重要概念。

  1. 强引用是最为常见的引用类型,是指存在一个对象的引用,它会防止对象被垃圾回收器回收。即使内存不足时,JVM也不会回收被强引用引用的对象,除非该对象的引用被明确地赋值为null。
java 复制代码
Object obj = new Object(); 
// obj是一个强引用
  1. 软引用是比较常用的引用类型之一,它用于描述一些还有用但并非必需的对象,软引用通常用于缓存数据,当内存不足时,JVM可以回收软引用的对象,从而释放缓存空间。当JVM需要内存时,会先回收这些软引用,如果空间仍然不足,才会抛出OOM异常。可以通过SoftReference类来实现软引用。
java 复制代码
SoftReference<Object> softRef = new SoftReference<>(new Object()); 
// softRef是一个软引用
  1. 弱引用与软引用类似,它也是用于描述一些还有用但并非必需的对象,但是与软引用不同,弱引用被回收的时机更加快速,我们可以使用弱引用来实现一些临时性的对象,比如缓存中的某些对象,当不再需要这些对象时,JVM会自动回收它们。在垃圾回收时,只要发现存在弱引用引用的对象,就会被回收。可以通过WeakReference类来实现弱引用。
java 复制代码
WeakReference<Object> weakRef = new WeakReference<>(new Object()); 
// weakRef是一个弱引用
  1. 虚引用是最为特殊的引用类型,它与前面的三种引用类型不同,虚引用并不会影响对象的生命期,而是用于在对象被回收时收到一个系统通知,可以实现资源的释放,比如文件句柄、网络连接等,如果我们直接使用强引用进行管理,容易出现资源泄露的问题。而使用虚引用则可以避免这个问题,因为虚引用在对象被回收时,会收到一个通知,然后程序可以在收到通知之后及时地释放资源。这样,程序员可以在对象被回收时进行一些清理操作。虚引用必须与ReferenceQueue(虚引用队列)一起使用。
java 复制代码
PhantomReference<Object> phantomRef = new PhantomReference<>(new Object(), referenceQueue);
// phantomRef是一个虚引用

GC的过程

在进行垃圾回收前,GC需要首先找出哪些内存对象是需要被回收的。这个过程称为垃圾标记,通常需要遍历整个堆空间,找出所有还在使用的对象。为了标记一个对象是否为垃圾,GC需要维护一个活动对象集合(Active Set)。一开始,所有对象都被认为是活动对象。然后,从根对象(如程序计数器、虚拟机栈、本地方法栈)开始,GC深度遍历所有可以被访问到的对象。如果一个对象无法被访问到,那么它就被认为是垃圾对象。

标记完垃圾对象后,GC便开始对其进行回收。垃圾回收完毕后,堆中的内存空间可能会变得非常零散。为了避免这种情况,GC会对堆中的对象进行移动和整理,使得所有的存活对象都能够在连续的内存空间中占据位置。这个过程称为内存整理。内存整理的主要工作是将所有存活对象移动到一端,然后清理出空闲的内存块。这个过程会涉及到对象的引用修改,需要将所有指向存活对象的引用进行更新。

当一个对象变成不可达时,它就成为了垃圾,需要被垃圾收集器回收。但是,垃圾收集器不会立即回收这个对象,而是把它放到F-Queue队列中,等待一个低优先级的线程在后台去读取这些不可达的对象。当线程调用这些对象的finalize()方法时,如果这个方法被覆盖过并且被调用过,那么虚拟机将视这个对象为不需要再执行finalize()方法了,否则它会被放回到待回收的集合中,等待下一次垃圾回收。如果在第二次标记时,这个对象还没有被重新关联到引用链上,那么就真的可以被垃圾回收器回收了。所以,finalize()方法实际上是一个对象的最后一次机会去逃脱垃圾回收的命运。

三色标记

三色标记算法是一种用于垃圾回收的算法,它可以识别并回收不再使用的内存空间,从而避免内存泄漏的问题。该算法实现的核心思想是通过将内存对象标记为三种状态中的一种来实现垃圾回收。三色标记算法将内存对象标记为白色、灰色和黑色三种状态。一开始,所有的对象都是白色的,表示这些对象都是可回收的垃圾。当程序运行时,每次访问一个对象时,该对象的状态会从白色变成灰色;灰色对象表示正在被垃圾回收器扫描的对象。当垃圾回收器遍历某个对象时,该对象被标记为灰色。在遍历完该对象的所有引用之后,该对象就被标记为黑色。如果某个灰色对象引用了某个白色对象,则该白色对象也被标记为灰色;黑色对象表示已经被垃圾回收器扫描到的对象。

通过三色标记算法,可以有效地避免内存泄漏问题,并实现高效的垃圾回收。值得注意的是,该算法需要在程序运行时频繁地标记对象的状态,因此可能会对程序的性能产生一定的影响。在三色标记算法中,如果存在循环引用问题,会导致算法无法正确地标记对象的颜色。例如,如果对象A引用了对象B,而对象B也引用了对象A,则在第一次标记时,A和B都会被标记为灰色,但是在扫描完A后,由于B还未被扫描,因此B的颜色仍然为灰色,而垃圾收集器并不知道这是一个循环引用的问题,因此会将B标记为黑色,从而造成垃圾回收器无法回收B。为了解决JVM三色标记算法中的循环引用问题,可以打破循环引用,常用的方法是使用"延迟引用"。具体来说,当遍历到一个对象的引用时,不立即标记为灰色,而是将它暂时记录下来,等到该对象被标记为黑色时,再将它标记为灰色。这样可以避免循环引用问题,同时也不会增加太多的开销。

JVM三色标记的工作原理可以概括为以下几个步骤:首先,垃圾回收器将所有对象都涂成白色。然后,从根对象开始遍历所有的对象,将所有可达的对象涂成灰色。在遍历过程中,如果发现某个灰色对象引用了某个白色对象,则将该白色对象涂成灰色。当所有可达对象都被涂成灰色后,垃圾回收器将所有黑色对象保留下来,将其余白色对象清除。最后,将所有黑色对象重新涂成白色。

跨代引用

跨代引用是指在堆内存中,年轻代中的对象被老年代中的对象引用的情况。当进行年轻代的垃圾回收(minor gc)时,需要判断哪些对象还需要保留,哪些对象可以被回收。如果按照常规思路,需要遍历老年代中所有的对象,非常耗费时间和性能。为了优化跨代引用的垃圾回收,JVM引入了一种抽象数据结构------记忆集。记忆集是非收集区域指向收集区域的指针集合,记录了老年代对象引用年轻代对象的指针。在进行年轻代垃圾回收时,只需要遍历记忆集中被标记的指针,就可以确定哪些对象需要保留,哪些对象可以被回收。

跨代引用主要有几种情况:第一种是将对象从年轻代移动到老年代时,需要将指向该对象的引用从年轻代的引用表中复制到老年代的引用表中,以确保对象在移动后仍能够被访问。第二种是在进行Full GC(Full Garbage Collection,即对整个堆空间进行垃圾收集)时,会遍历整个堆空间。如果在堆空间中发现一个对象被另一个对象所引用,且该被引用的对象在老年代中,而引用该对象的对象在年轻代中,就需要进行跨代引用。第三种是在进行压缩垃圾收集时,需要将所有可达对象移动到内存区域的起始位置。如果一个对象在年轻代中,而它所引用的对象在老年代中,就需要进行跨代引用。

记忆集采用了一些优化机制,如卡表和写屏障,避免了全局扫描老年代的低效率问题。卡表是一个大小等于老年代的位图,它将老年代按照固定大小(默认为512B)分成很多个区域,每个区域对应卡表中的一个位。当年轻代中的对象与老年代中的对象建立关联时,虚拟机会将这个老年代区域对应的卡表位标记为"脏",表明它需要被扫描。这样,GC时只需要扫描所有被标记为"脏"的老年代区域,而不是全局扫描老年代。写屏障也是一种优化机制,它用于捕获在年轻代中产生的对象引用,将其放入到卡表中。当年轻代中的对象被分配内存时,虚拟机会通过写屏障来监视对象的引用情况。如果有一个对象的引用发生了变化,比如一个对象被移动到了另一个区域,虚拟机会通过写屏障将这个对象的新引用信息更新到相应的卡表中,保证卡表的准确性和正确性。这样,JVM在进行垃圾回收时,可以避免不必要的扫描和浪费,提高了垃圾回收的效率和性能。

内存泄漏与堆积、溢出

内存泄漏是程序在分配内存后,由于设计或编写缺陷无法释放已分配的内存,从而导致系统或进程逐渐耗尽可用的内存空间。一般有三种原因:第一种是变量未销毁,即定义并分配内存的变量在程序运行结束后未被销毁,会导致内存泄漏;第二种是指针未及时释放内存,以指针的形式分配内存后未及时释放会产生内存泄漏;第三种是内存管理错误,通常是程序中使用错误的内存分配和释放方法,例如使用了malloc/new分配内存但未使用free/delete释放内存。

内存泄漏通常会导致程序运行变慢或崩溃,因此可以使用编译器调试工具如Visual Studio等捕获内存泄漏,然后跟踪变量,检查变量是否及时释放,还可以使用内存管理工具如Valgrind检测和调试内存泄漏,最后可以使用智能指针来避免内存泄漏,智能指针可以自动管理内存空间,避免内存泄漏的发生。

内存泄漏会让内存不停地增加,最后会爆满,导致程序崩溃。这种情况通常是由代码导致的。我们可以用visualVM这个工具来进行内存转储,查看哪个类占用了太多的内存空间,然后再检查它所引用的实例和引用。最后,我们可以定位到代码的具体问题。如果我们的堆内存很大,使用visualVM产生的资源成本太高,我们可以尝试使用轻量级的jmap工具来生成堆转储快照进行分析,这种方法与使用visualVM的思路相同。

内存溢出就是当程序试图向内存申请空间时,由于申请的空间太大超出了系统或进程可分配的内存空间,导致程序无法正常运行。内存溢出的原因主要有三种,第一种是申请空间过大,当程序向内存申请过大的空间时,容易导致内存溢出,可以使用分片申请空间的方法来避免。第二种是内存泄漏,即使程序本身没有缺陷,也可能因为内存泄漏导致内存耗尽从而造成内存溢出。第三种是错误的内存管理,例如使用了错误的内存分配和释放方法或指针操作错误等。为了避免内存溢出,可以在程序开始时预留一定的空间,使用内存池提高程序效率,使用Memcheck、Purify等工具进行内存溢出分析报告,改进内存管理方法使用智能指针等方法减少内存泄漏和溢出的问题,采用一些有效的内存优化技术减少内存占用提高程序效率和稳定性。

JVM调优经验

在JVM中,FGC指的是全垃圾收集,这是一个对整个堆内存进行垃圾回收的过程。然而,它也会让应用程序暂停,并且会影响应用程序的性能,这是我们不想看到的。FGC通常在以下情况下发生:首先是堆内存不足,当堆内存不足时,JVM会启动FGC以释放内存空间。其次是大量对象生成,当应用程序生成大量对象时,堆内存可能会很快被占满,此时JVM会触发FGC。还有一种情况是对象生命周期短,如果应用程序中大量对象的生命周期很短,那么这些对象很快就会成为垃圾,导致JVM启动FGC。为了减少FGC的出现,我们可以采取以下策略。首先,增加堆内存的大小可以减少由于内存不足而导致的FGC。其次,通过对代码进行优化,减少不必要的对象生成,可以减少FGC的发生。此外,我们可以在对象的生命周期结束后尽可能地重用这些对象,避免频繁的对象生成和回收。还有一种方法是使用对象池等技术,这可以减少对象的创建和销毁,从而减少FGC的发生。最后,在程序需要暂停的空闲时间,可以手动触发System.gc()方法,对垃圾进行回收,从而减少FGC的发生。

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

JVM调优其实十分复杂,针对不同场景的问题,我们可以从以下几个角度进行设计:

首先,如果是大访问压力下,MGC频繁一些是正常的,只要MGC延迟不导致停顿时间太长或者引发FGC,可以适当增大Eden空间大小,降低频繁程度。当然,要注意空间增大对垃圾回收产生的停顿时间增长是否可以接受。

其次,如果是MinorGC频繁且容易引发Full GC,需要分析MGC存活对象的大小,是否能够全部移动到S1区。如果S1区大小小于MGC存活对象大小,这批对象会直接进入老年代。这种情况下,应该在系统压测的情况下,实时监控MGC存活对象的大小,并合理调整Eden和S区的大小以及比例。

第三,如果由于大对象创建频繁导致Full GC频繁,可以通过控制JVM参数来优化对象的大小。如果代码层面无法优化,则需要考虑调高参数的大小,或者定时脚本触发Full GC,尽量保证该对象确实是长时间使用的。

第四,如果MGC和FGC的停顿时间长导致影响用户体验,需要考虑减少堆内存大小,包括新生代和老年代。也要考虑线程是否及时达到了安全点,查看安全点日志并对代码进行针对性调整。

最后,如果出现内存泄漏导致MGC和FGC频繁,就需要对代码进行大范围的调整,例如大循环体中的new对象,未使用合理容器进行对象托管等等。无论如何,JVM调优的目的就是在系统可接受的情况下达到一个合理的MGC和FGC的频率以及可接受的回收时间。

2.深入理解MySQL关系型数据库

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

索引数据结构

B树和B+树都是基于平衡多叉树的结构,用于快速查找和排序大量数据。B树的每个节点可以存储关键码和数据,而B+树只在叶子节点中存储数据,非叶子节点仅存储索引信息。B+树相比B树具有更高效的磁盘IO、更适合范围查询和排序以及插入和删除操作更加高效等优势。在查询数据时,B+树的叶子节点包含所有的关键字数据,而非叶子节点仅仅包含索引数据,从而能够更好地适应范围查找和排序操作。

MySQL是从磁盘读取数据到内存的,是以磁盘块为基本单位的,位于同一磁盘块中的数据会被一次性读取出来,不是按需读取。InnoDB存储引擎使用页作为数据读取单位,页面是其磁盘管理的最小单位,一页的大小默认为16kb。系统的一个磁盘块的存储空间往往没有这么大,所以InnoDB每次申请磁盘空间时都会是多个地址连续磁盘块来达到页的大小16KB。在查询数据时,一个页中的每条数据都能定位数据记录的位置,这会减少磁盘I/O的次数,提高查询效率。InnoDB存储引擎在设计时是将根节点常驻内存的,力求达到树的深度不超过3,也就是说I/O不超过3次。

结合B树和B+树的特点以及对磁盘的分析,我们可以看出,B+树更适合大量数据的储存和查询。B+树的叶子节点之间通过指针串联,形成一个有序链表,因此在进行区间查询时只需要遍历叶子节点即可,数据访问效率更高。B+树的非叶子节点数目比B树的节点数目大得多,因为B+树的非叶子节点只存储关键码,因此可以显得更矮胖。B+树相比于B树,高度更低,因而访问更快。通过对数据库索引结构和磁盘基础设施的了解,我们可以更好地理解和优化数据库查询性能。

隔离级别、脏读、 不可重复读、幻读、幻影行

在数据库中隔离级别是多个事务之间可以看到对方对数据的更改情况。比如,一个事务在修改数据时,另一个事务能不能够看到数据在修改,这些修改能不能可以取消。目前常见的隔离级别有四种:读未提交、读已提交、可重复读和串行化。

举个例子,假设有两个人Tom和Jerry同时向银行存款,Tom存了100元,Jerry存了200元。

如果他们的事务隔离级别为读未提交,那么在Tom存款未提交之前,Jerry就可以看到Tom的存款已经生效了。但如果Tom的存款被回滚,Jerry之前看到的数据就是脏数据。读未提交隔离级别是最低的隔离级别,它允许一个事务读取另一个事务未提交的数据。这可能会导致脏读的情况,也就是读取到了未提交的数据,如果数据回滚,读取的数据将变得无效。

如果隔离级别为读已提交,那么只有在Tom的存款事务提交后,Jerry才能看到已经生效,这意味着读已提交隔离级别会引入小幅的延迟,因为Jerry必须等待Tom的事务提交才能看到结果。读已提交隔离级别要求一个事务只能读取另一个已经提交了的数据,这样就避免了脏读出现的情况。但它可能会导致不可重复读的问题,也就是在同一事务内,同样的查询条件下多次查询同一数据,但是得到的结果不同。这是因为另一个事务在该事务两次查询之间修改了数据。

如果隔离级别为可重复读,那么Jerry可以在Tom的事务提交前多次查询,因此数据的一致性得到更好的保障,但是会消耗更多的系统资源来维护一致性。可重复读隔离级别要求一个事务在执行过程中多次查看同样的数据,它能够保证在一个事务内多次查询同一数据时得到的结果是一致的。但它可能会导致幻读的问题,也就是在同一事务内,同样的查询条件下多次查询数据,但是得到的结果不同,这与不可重复读的区别在于幻读是由于另一个事务插入了新数据导致的,而不是修改数据。

如果隔离级别为串行化,那么Tom和Jerry的存款事务必须一个一个地执行,不能同时进行,这意味着一个事务必须在另一个事务完成之后才能执行,这将会带来更高的延迟和更大的系统资源开销。串行化隔离级别是最高的隔离级别,它要求所有的事务串行执行,避免了并发访问产生的所有问题。但它会导致更高的延迟和更大的系统资源开销。

MySQL默认的隔离级别是可重复读,这是因为MySQL认为可重复读是一个良好的默认隔离级别,可以提供足够的隔离性和性能。在可重复读隔离级别下,每个事务读取的数据都是一致的,即使其他事务对数据进行了修改,它们的修改也不会影响到当前事务的读取结果。另外,可重复读隔离级别也可以提供足够的性能。因为它不会对读取数据加锁,而是使用多版本并发控制(MVCC)机制来实现隔离性。这可以避免了对数据的过度访问和锁竞争,从而提高了并发性能。

可重复读可以避免脏读和不可重复读的问题,但存在幻读问题,并且在MySQL 5.7版本中将其作为一个已知的问题公开了。在MySQL 8.0版本中引入了一种新的隔离级别------可重复读快照隔离级别,它可以解决幻读问题,同时保持了可重复读级别的并发性能。它是在可重复读隔离级别的基础上做的优化。

可重复读快照隔离级别的实现方式是在事务开始时,创建一个事务快照,这个快照包含了所有在事务开始之前已提交的数据。在事务执行过程中,读取的都是这个快照中的数据,而不是直接读取数据库中的数据。事务执行过程中,其他事务对数据的修改不会影响到正在执行的事务。这样的话,对于同一个事务,在可重复读隔离级别下,多次读取同一数据时,得到的结果都是一样的。可重复读快照隔离级别与可重复读隔离级别最大的区别在于当有新的事务加入时,可重复读隔离级别下的事务会重新建立快照,而在可重复读快照隔离级别中,事务快照只会在事务开始时被建立,因此这个隔离级别的并发性能更好。

只不过可重复读快照隔离级别不是绝对安全的,因为在事务执行过程中,如果有其他事务对数据进行了删除操作,那么当前事务在读取数据时可能会出现"幻影行"的情况。在数据库中,幻影行指的是一个事务在执行查询操作时,可能会发现一些之前不存在的行或者少了一些行,这些行就像幻影一样突然出现或消失了。可重复读快照隔离级别只能保证读取到的数据与事务开始时相同,但它并不能防止其他并发事务在事务执行过程中更新或插入数据。所以,当一个事务在读取数据时,如果同时有其他事务在对数据进行增删改操作,就可能会出现幻影行的情况。

为了解决这个问题,需要使用行级锁或使用串行化隔离级别。行级锁是指在读取数据时,锁定当前使用的行,防止其他事务同时对该行进行修改,保证当前事务读取的是一致的数据。对于幻影行问题,当一个事务在执行查询时,如果发现其他事务正在进行插入、更新或删除操作,该事务会锁定当前查询的行,直到其他事务操作完成后再进行查询,从而避免出现幻影行。

使用串行化隔离级别时,所有事务都将被串行化执行,即每个事务执行时都需要等待前一个事务执行完成后才能开始执行,从而避免出现幻影行。在串行化隔离级别下,所有的数据读取和修改操作都需要通过共享锁或独占锁来保证数据的一致性和可靠性。虽然串行化隔离级别可以解决幻影行的问题,但由于会对并发性能造成较大的影响,因此只有在确实需要时才应该使用。

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

行锁的表现

将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的会话持有了锁。

间隙锁(Gap Lock)是MySQL中的一种特殊锁机制,用于保证事务的隔离性。

假设有这样一张表:

java 复制代码
CREATE TABLE students (
    id INT PRIMARY KEY,
    name VARCHAR(50),
    age INT
);

现在有两个事务:

  • 事务A:要插入数据INSERT INTO students(id, name, age) VALUES (1, 'Alice', 18)
  • 事务B:要查询数据SELECT * FROM students WHERE id = 2

假设事务A先执行,会在id为1的记录上加上一个间隙锁,这个间隙锁会锁定id为2的那个间隙,如下图所示:

java 复制代码
|        TX A       |         |        TX B       |
|--------------------+---------+--------------------|
|id=1 (Gap Lock)  |         |id=2              |

事务B现在要查询id为2的记录,但由于id为2的间隙被事务A锁定,所以事务B需要等待事务A提交或回滚才能进行查询,如下图:

java 复制代码
|        TX A       |         |        TX B       |
|--------------------+---------+--------------------|
|id=1 (Gap Lock)  |         |                    |
|                    |         |waiting for id=2|

如果在事务A提交或回滚之前,有其他事务想要在id为2的间隙中插入数据,那么该事务会被阻塞,直到间隙锁被释放。

间隙锁的存在,可以防止幻读现象的发生。例如,如果去掉间隙锁,那么在事务A执行插入数据之前,如果事务B插入了一条id为2的记录,那么事务A在执行插入时,会发现id=2已经存在,从而引发幻读。

因此,间隙锁是MySQL中一个非常实用的锁机制。

死锁

MySQL死锁是指两个或多个事务正在相互等待对方持有的锁,导致它们都无法继续执行。这时,MySQL会检测到死锁并强制终止其中一个事务,以便另一个事务可以继续执行。

以下是一些常见的MySQL死锁面试相关问题及其答案:

  1. 什么是MySQL死锁?

MySQL死锁是指两个或多个事务都在等待对方持有的锁,导致它们都无法继续执行的情况。

  1. MySQL如何检测到死锁?

MySQL会不定期地进行死锁检测,如果检测到死锁,会把其中一个事务终止,并回滚该事务执行的操作。

  1. 如何避免MySQL死锁?

避免MySQL死锁的方法主要有三种:

(1)通过减少交叉事务的数量来降低死锁发生的概率;

(2)通过加锁时的顺序来避免死锁;

(3)通过增加超时时间来解决死锁。

  1. 如何解决MySQL死锁?

解决MySQL死锁的方法主要有两种:

(1)终止其中一个事务并回滚该事务执行的操作;

(2)调整事务执行的顺序来避免死锁。

  1. 如何排查MySQL死锁?

排查MySQL死锁的方法主要有两种:

(1)查看MySQL日志,找到死锁发生的时间和事务ID;

(2)使用SHOW ENGINE INNODB STATUS命令查看当前正在运行和等待的事务信息,并分析死锁情况。

总之,MySQL死锁是MySQL数据库中常见的问题之一,对此需要进行深入的了解和掌握,以避免和解决这种情况。

原子性底层实现原理(undo log日志 )

原子性是指一个操作要么全部执行成功,要么全部执行失败,不存在部分执行的情况。在数据库底层实现中,为了保证事务的原子性,通常采用undo log日志来实现原子性,记录事务执行前的数据状态,以便在发生错误或者回滚时恢复数据原始状态。Undo log日志记录了数据库操作的所有细节,包括修改的数据和修改前的值。

具体的实现原理如下:

  1. 在事务执行之前,先将需要修改的数据放入buffer pool中的内存页中,并将修改前的数据复制一份放入undo log中。
  2. 事务执行时,将对应的数据进行修改,并将修改后的数据记录到redo log中。
  3. 如果事务执行成功,则将redo log中的数据更新到磁盘上的数据文件中。同时将undo log中的数据删除或者标记为已提交。
  4. 如果事务执行失败或者需要回滚,则从undo log中读取修改前的数据,恢复原始数据状态。

举个例子:假设用户要将某个账户的余额从100元增加到200元,系统执行以下步骤:

  1. 记录当前余额为100元。
  2. 在缓存区中将余额增加到200元。
  3. 等待操作确认。
  4. 操作确认,提交缓存区中的数据。
  5. 完成。

如果在第3步或第4步发生异常,系统会根据undo log日志将缓存区中的余额恢复为100元,保证操作的原子性。

一致性底层实现原理

MySQL的一致性是指在多线程并发访问数据库时,数据始终保持一致的状态。

实现一致性的方式主要包括以下方面:

1. 事务机制

MySQL通过事务机制来保证数据一致性。事务是指一组操作序列,这些操作要么都执行成功,要么全部撤回,保证数据的完整性和一致性。在MySQL中,通过使用事务来对数据进行读写操作,可以保证数据的一致性。

2. 锁机制

MySQL通过锁机制来保证数据一致性。

当多个线程同时对同一数据进行读写操作时,可能会出现数据不一致的情况,比如一个线程在修改数据的过程中,另一个线程也读取了这些数据,导致了数据的不一致。为了解决这个问题,MySQL引入了锁机制。

锁是一种保护机制,可以防止多个线程同时对同一数据进行读写操作,影响数据的一致性。锁分为共享锁和排他锁,共享锁允许多个事务并发读取同一数据行,但不允许写入;排他锁则只允许一个事务读取或写入数据行。MySQL采用多粒度锁定机制,即对各层级的数据对象(如行、表、页)进行锁定,从而实现细粒度的数据访问控制。

举个例子来说,假如有三个线程同时要访问同一张表,第一个线程要进行写操作,第二个线程要进行读操作,第三个线程要进行写操作。这时候MySQL就会根据锁的机制将这些线程进行分配,第一个线程会被授予排他锁,第二个线程会被授予共享锁,第三个线程也会被授予排他锁。这样就可以保证在同一时间内,只有一个线程能够对表中的数据进行写操作,避免了数据的不一致。

3. 隔离级别

MySQL 支持多种隔离级别,如读未提交、读已提交、可重复读和串行化,可以根据情况选择适当的隔离级别以确保数据的一致性。如果应用需要高度的数据一致性,可以选择可重复读或串行化隔离级别;如果应用对数据一致性要求较低,可以选择读已提交隔离级别;如果应用对性能要求较高,可以选择读未提交隔离级别。

4. MVCC

多版本并发控制(MVCC)是一种用于在多个事务同时访问同一个数据时保证数据一致性的技术。MySQL 使用 MVCC 机制来避免数据的读写冲突,确保数据的一致性。

在MVCC中,每个事务看到的数据都是独立的版本,这些版本是在事务开始之前生成的。这意味着在多个事务同时访问同一个数据时,每个事务都能看到自己的版本,而不会影响到其他事务的数据。MySQL使用两种方式来实现MVCC,一种是乐观锁,一种是悲观锁。在MVCC中,读操作可以不加锁,不会对其他事务造成阻塞,而写操作则需要加锁。

乐观锁机制是指事务在进行读操作时,只会复制数据的快照版本,而不是实际的数据版本。因此,当多个事务同时读取数据时,每个事务都可以看到自己的版本,而不会影响其他事务的读取。在写入数据时,MySQL会为写入的数据生成一个新版本,在写入之前会检查该数据是否被其他事务修改过,如果有,则会回滚该事务,再次尝试写入。这种机制可以有效地减少锁冲突,提高并发性能。在读操作较多、写操作较少的场景下,或者在对于数据一致性要求不高但是需要没有脏写问题的场景下,使用乐观锁能够提高并发性能。

悲观锁机制则是在读写操作时,直接对数据进行加锁,其他事务需要等待锁被释放才能进行操作。这种机制会对并发性能造成一定的影响,但可以确保数据的一致性。数据一致性要求较高的场景下,或者在写操作较多、读操作较少、写操作时间较长的场景下,悲观锁可以避免读操作和写操作的冲突。

MySQL使用MVCC机制来避免数据的读写冲突,确保数据的一致性。通过生成数据的快照版本和加锁机制的处理,可以有效地提高并发性能,保证数据的安全性和一致性。

MySQL 通过使用多种技术和机制来确保数据的一致性,从而保证了数据的可靠性。

持久性底层实现原理(redo log机制)

持久性是指在数据库系统中,当一个事务提交后,该事务所做的更改操作必须被永久保存在数据库中,不能因为系统故障或其他原因而丢失。Redo log机制是一种常见的实现持久性的方式。将对数据的修改操作记录在一个日志文件中,并在每一次操作之后将该日志文件强制刷入到磁盘中,以保证即使在数据库系统发生崩溃时,也能从日志文件中恢复数据的一致性。

底层实现原理是:redo log机制是由InnoDB存储引擎实现的,mysql 的数据是存放在这个磁盘上的,但是每次去读数据都需要通过这个磁盘io,效率就很低。InnoDB存储引擎将每个事务的修改操作记录在一个称为redo log的循环缓冲区buffer中,这个 buffer 中包含了磁盘部分数据页的一个映射,作为访问数据库的一个缓冲,从数据库读取一个数据,就会先从这个 buffer 中获取,如果 buffer 中没有,就从这个磁盘中获取,读取完再放到这个 buffer 缓冲中,当数据库写入数据的时候,也会首先向这个 buffer 中写入数据,定期将 buffer 中的数据刷新到磁盘中,进行持久化的一个操作。如果 buffer 中的数据还没来得及同步到这个磁盘上,这个时候 MySQL 宕机了,buffer 里面的数据就会丢失,造成数据丢失的情况,持久性就无法保证了。使用 redolog 解决这个问题,当数据库的数据要进行新增或者是修改的时候,除了修改这个 buffer 中的数据,还会把这次的操作写入到这个 redolog 中,如果 msyql 宕机了,就可以通过 redolog 去恢复数据,redolog 是预写式日志,会先将所有的修改写入到日志里面,然后再更新到 buffer 里面,让这个数据不会丢失,保证了数据的持久性。另外,redo log缓冲区的大小是可配置的,一旦缓冲区被填满,InnoDB存储引擎就会将缓冲区中的内容刷新到磁盘上的redo log文件中,并在记录日志前将缓冲区中的数据刷入到磁盘中。

InnoDB存储引擎还将每个数据页的修改操作也记录在了对应的redo log文件中。在进行数据恢复时,InnoDB存储引擎会首先将已提交的事务的redo log从redo log文件中读取出来,然后通过redo log中的信息对数据进行恢复操作。由两部分组成:一是内存中的重做日志缓冲,是易丢失的;二是重做日志文件,是持久的。

隔离性底层实现原理(MVCC多版本并发控制)

MVCC多版本并发控制可以保证数据的隔离性。它基于"多个版本"的概念,每个版本都有自己的时间戳。不同的事务同时执行时,它们会看到不同的数据版本,这样一个事务修改数据时,不会影响其他事务的读操作,而其他事务读取到的是之前的版本,而不是被修改后的版本。这也可以避免数据的"脏读"问题。

具体实现原理是:每个数据行会记录其修改的版本号(也称为"时间戳")。当一个事务要读取某个数据行时,它会先检查该行的版本号和其开始时间进行比较,如果版本号大于等于该事务的开始时间,那么该事务就可以读取该数据行。如果小于该事务的开始时间,那么该数据行就不适合该事务读取,因为该数据行已经被其他事务修改过了。

当一个事务要修改某个数据行时,它会创建一个新版本,并将新版本的版本号设置为该事务的开始时间。然后,该事务执行修改操作,并在新版本中记录修改后的值。这个过程是原子性的,即修改操作要么全部成功,要么全部失败。当事务提交时,它会将新版本的版本号设为提交时间。

这样,其他事务在读取该数据行时,如果版本号小于等于它的开始时间,则可以读取该版本的值;如果版本号大于它的开始时间,则需要读取其他版本的值。通过这种方式,MVCC多版本并发控制实现了高效的隔离性,并且还能避免数据的"脏读"问题。

MySQL的底层实现原理涉及到写-写操作和写-读操作。对于写-写操作,MySQL采用加锁来保证并发控制,其原理和Java中的锁机制相同。而对于写-读操作,MySQL使用MVCC(多版本并发控制)机制,避免频繁加锁互斥来保证隔离性。

MVCC机制的实现基于两个机制:读取视图(read-view)和版本链(undo)比对机制。对于每个被修改的行数据,默认情况下,MySQL会保留修改前的数据undo回滚日志,并用两个隐藏字段trx_id和roll_pointer串联起来形成一个历史记录版本链。在可重复读隔离级别下,执行任何查询SQL都会生成当前事务的一致性视图read-view,即生成一个版本。该read-view视图在事务结束之前不会变化。而在读已提交隔离级别下,在每次执行查询SQL时,都会重新生成read-view视图,即每次select都会生成一个版本。

在执行查询时,MySQL会从版本链最新的数据开始,逐条与read-view做比对。如果当前事务的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 中的数据刷新到磁盘中,进行持久化的一个操作。

BufferPool缓存是一个大小可调整的内存池,它由多个缓存页组成。每个缓存页的大小默认为16KB,可以根据需要进行调整。当MySQL服务器需要读取或写入数据时,它会将数据按照一定的规则存放在BufferPool缓存中。

在缓存中存储的数据会根据其使用频率进行淘汰。当缓存页的空间不够时,MySQL会根据LRU算法(最近最少使用)将最不常用的缓存页替换出来,以保证缓存中存储的数据总是最有用的。

通过使用BufferPool缓存机制,MySQL可以显著提高查询效率,减少磁盘I/O的次数,从而提高数据库的性能。

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

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

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

MySQL的BufferPool缓存机制可以提高数据库查询效率,但也有一些弊端:

  1. 内存使用过多:BufferPool缓存机制需要占用一定的内存空间,当数据库中数据量非常大时,会占用大量的内存空间,可能会导致系统内存不足。

  2. 热数据的维护:BufferPool缓存机制只能缓存最近访问的数据,对于长时间不访问或很少访问的数据,缓存效果并不理想,需要花费额外的时间和资源从磁盘中读取。

  3. 数据的一致性:如果缓存中的数据与磁盘上的数据不一致,可能会导致数据丢失或数据不一致的情况发生。

  4. 磁盘IO操作的影响:如果缓存中的数据过多,可能会影响磁盘IO操作的效率,导致系统负载增加,从而影响数据库的查询效率。

  5. 缓存命中率的限制:BufferPool缓存机制只能缓存一部分数据,无法保证所有的查询都能从缓存中获取数据,如果缓存命中率不高,查询效率仍然会受到影响。

主键自增长实现原理

MySQL 主键自增长的实现原理,其实涉及到数据库设计和计算机科学中的自动编号机制。通俗来讲,就是数据库在新增一条记录时,可以自动为该记录生成唯一的标识符,也就是主键值。下面我们来详细解释这个过程。

1. 定义自增长主键

在 MySQL 中定义一个自增长主键,需要使用 AUTO_INCREMENT 关键词。例如:

sql 复制代码
CREATE TABLE students (
    id INT NOT NULL AUTO_INCREMENT,
    name VARCHAR(30) NOT NULL,
    PRIMARY KEY (id)
);

这里的 id 列就是自增长主键,MySQL 会自动为它生成唯一的值。

2. 实现自增长

当插入一条新记录时,MySQL 会检查表结构中是否有自增长主键。如果有,会寻找当前最大的主键值,然后在此基础上加 1,生成新的主键值。这个过程是在内存中完成的,速度非常快。

在InnoDB存储引擎的内存结构中,对每个含有自增长值的表都有一个自增长计数器。当对含有自增长的计数器的表进行插入操作时,这个计数器会被初始化。插入操作会依据这个自增长的计数器值加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方式实现。在这种配置下,如果不考虑回滚操作,对于自增值的列,它的增长还是连续的。区别在于如果使用了AUTO-INCLocking方式去产生自增长的值,这个时候再进行简单插入操作,就需要等待AUTO-INCLocking释放。

2:在这个模式下,对于所有的插入的语句,它自增长值的产生都是通过互斥量,不是通过AUTO-INCLocking方式,这是性能最高的方式,但是如果是并发插入,在每次插入的时候,自增长的值就不是连续的,而是根据锁的竞争情况产生的。这就会导致主从复制的方式SBR(statement-based replication)出现问题,因为主从之间的自增长值不一致会导致数据不一致的情况。因此,如果使用SBR进行主从复制,不建议将innodb_autoinc_lock_mode的值设置为2。而使用row-based replication(RBR)可以确保在主库上使用互斥量产生自增长值,并在从库上使用相同的方法生成相同的自增长值。这样就可以在保证并发性能的同时,保持主从复制之间数据的一致性。

使用mysql自增长的坏处:

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

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

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

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

3. 锁机制

为了保证自增长主键的唯一性,MySQL 会在插入新记录时对表进行加锁,防止其他同时进行的操作干扰。具体来说,MySQL 会在表级别上加一个排他锁,也就是 WRITE 锁。这会阻塞其他的写操作,直至当前操作完成。

4. 如何处理插入失败

如果在插入新记录时出现冲突,也就是主键值已经存在,MySQL 会返回一个错误。这时候,我们可以根据实际情况进行处理,例如重试或者更新已有记录等。

总之,使用自增长主键可以方便地实现记录的唯一标识,提高数据库查询效率和数据完整性。不过在使用过程中,需要注意锁机制和异常处理等问题。

索引失效、聚集索引、辅助索引、覆盖索引、联合索引

0.索引失效的几种情况?

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

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

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

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

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

1. 什么是聚集索引?

聚集索引定义了表中数据的物理顺序,并且每个表只能有一个聚集索引。聚集索引按照指定列的顺序来存储表中的数据。当对基于聚集索引的列进行查询时,数据库引擎能够很快地找到指定的数据。

InnoDB存储引擎的表是一种按主键顺序存放数据的表格。聚集索引是一种按照主键构建的B+树,其中叶子节点存储整张表的行记录数据。这些叶子节点被称为数据页,并通过双向链表相互连接。因为每张表只能有一个聚集索引,所以查询优化器常常会选择使用聚集索引,因为它可以在数据页上直接找到所需的数据,排序和范围查询速度非常快。如果需要查询某个范围内的数据,可以通过叶子节点的上层中间节点得到页的范围,之后直接读取数据页即可。比如,如果我们想查询一张注册用户的表中最新注册的10位用户,就可以通过简单的SQL查询语句 SELECT * FROM Profile ORDER BY id LIMIT 10; 轻松实现,而不需要额外的数据排序操作。

2. 什么是辅助索引?

辅助索引也称为非聚集索引,它建立在聚集索引或堆(没有聚集索引)的基础上。辅助索引的作用是提高查询的性能,辅助索引并不定义数据的物理顺序,而是通过指向数据的逻辑指针来访问数据的。

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

3. 什么是覆盖索引?

覆盖索引是一种特殊的辅助索引,它包含了查询所需要的所有列数据,因此无需再到聚集索引或堆中去查找数据。这样的索引查询效率非常高,可以大大提高查询性能。

4. 如何创建一个联合索引?

联合索引是基于多个列的组合来创建的,它可以使得查询的效率更高。创建联合索引的语法如下:

CREATE INDEX index_name ON table_name (column1, column2, ...)

其中,index_name 为索引的名称,table_name 为表的名称,column1、column2、... 为列名。需要注意的是,联合索引的列顺序非常重要,查询时必须按照索引列的顺序来查询才能发挥索引的作用。

5. 聚集索引与辅助索引的优缺点:

聚集索引的优点是能够快速查找某个指定的条目,因为它们能够让数据在磁盘上物理地按照顺序存储,可以提高查询效率。但是,聚集索引缺点是插入和更新数据变慢,因为需要重新排序数据。

辅助索引的优点是不会对插入和更新操作产生影响,因为它们并不参与数据物理顺序的排序,查询较快。缺点是因为需要通过逻辑指针找到数据,因此查询速度比聚集索引慢一些。

6. 什么情况下应该使用覆盖索引?

使用覆盖索引的情况是当查询只需要查询索引列时,可以使用覆盖索引来提高查询速度。因为覆盖索引包含了所有需要查询的列,而不需要再到聚集索引或堆中去查找数据。

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)遵循最左前缀原则:尽量确保查询中的索引列按照最左侧的列进行匹配。例如如果为(a, b)和(c, d)创建了联合索引,查询示例,代码如下:

SELECT * FROM table WHERE a = ? AND b = ?

将使用索引,以下查询,代码如下:

SELECT * FROM table WHERE c = ? AND d = ?

将无法使用索引。

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个接口提供思路以及关键代码。

根据主表创建子表接口,代码如下:

//第6章/6.9.1 主表创建子表

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,代码如下:

子表结构生成的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不存在,近期没发生写操作,暂时路由到从库。

3.深入理解Redis缓存

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

多路复用模式

在Redis中,I/O多路复用技术是用于处理多个客户端同时发起请求的技术。在Redis 2.6版本及之前,Redis使用的是select系统调用实现的简单的I/O多路复用技术。每当有一个请求到达时,Redis会遍历每个客户端对应的文件描述符,判断是否有数据可读写。这种方式虽然可以实现多个客户端的请求处理,但是在高并发场景下,效率比较低,因为它会在每个客户端之间轮询,导致CPU利用率不高。

为了提高Redis的性能,Redis 2.8版本使用了更为高效的I/O多路复用技术,即epoll(对于MacOS系统,使用的是kqueue)。每当有一个请求到达时,Redis会将该请求添加到一个事件队列中,然后将该事件队列与epoll实例绑定。epoll会监听该事件队列上的所有事件,当事件触发时,Redis会处理该事件并从事件队列中删除。这种方式避免了在每个客户端之间轮询,从而提高了CPU的利用率和效率。

举个例子,假设有1000个客户端同时向Redis发起请求,使用select方式时,Redis需要轮询每个客户端的文件描述符,判断是否有数据可读写,轮询完所有的客户端之后才能开始处理请求。而使用epoll方式时,Redis会将所有的请求添加到事件队列中,并与epoll实例绑定,当有请求到达时,epoll会监听该事件队列上的所有事件,直接触发事件处理,从而提升了性能和效率。

单线程和多线程模型

Redis的单线程模型是指Redis服务器使用一个线程处理所有客户端请求,这个线程会依次处理每一个客户端请求,并将请求放入一个队列中。如果Redis需要执行的操作需要访问外部资源(如读写磁盘),则会将这个操作放入I/O多路复用的事件轮询器中,等待对应的事件发生,否则会一直等待。由于单线程模型没有线程切换的开销,可以避免竞态条件和锁的开销,从而具有高效、可靠和简单等优点。

举个例子,假设有两个客户端同时发送操作请求给Redis服务器,第一个客户端需要进行写入操作,第二个客户端需要进行读取操作。在单线程模型中,Redis会先处理第一个客户端的写入请求,将其放入队列中,等待操作完成。当Redis完成第一个请求后,再去处理第二个客户端的读取请求。

但是单线程模型也有一些缺点,例如处理大量大键值的操作时,Redis会因为阻塞其他客户端请求而导致性能下降,甚至服务超时。在Redis 3.x版本中尤其明显,这是由于在执行大key删除操作时,Redis需要遍历整个数据库并删除所有符合条件的键值对,这个过程会阻塞其他客户端请求,导致服务性能下降。

为了解决这个问题,Redis 4.x版本引入了多线程模型,支持部分多线程操作。在4.x版本中,当Redis需要执行大key删除操作时,会启动一个子线程处理这个操作,从而避免了在主线程中执行这个操作的问题。同时,主线程也会继续处理其他客户端的请求,提高了服务的并发处理能力。

Redis 6.x版本则完全采用多线程模型,主线程用于处理客户端请求和分配任务给工作线程,而工作线程则执行实际的键值存储和更新操作。Redis 6.x版本中引入了一个底层库叫做Redis Modules API,它允许开发者编写自定义的模块,以扩展Redis的功能。同时该版本加入了强一致性模块,可以保证所有节点数据的一致性。

总体来说,Redis的单线程模型具有高效、可靠和简单等优点,但在处理大量大键值的操作时性能下降。多线程模型可以解决单线程模型的性能问题,但也增加了系统的复杂性。在实际应用中,需要根据具体的场景选择合适的Redis模型。

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,有序的集合,可以做范围查找,比如说排行榜,展示当日排行前十。

简单字符串、链表、字典、跳跃表、压缩列表

简单字符串的底层编码分为三种,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字节的数据。

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

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

列表保存元素个数小于512个,每个元素长度小于64字节的时候触发机制会使用ziplist(压缩列表)编码,否则使用linkedlist(双端链表)。在redis.conf(linux系统)或者redis.windows.conf(windows系统)对应的文件改配置这二个配置,设置触发条件选择编码。比如我修改列表保存元素个数小于1024个并且每个元素长度小于128字节时使用ziplist(压缩列表)编码,否则使用linkedlist(双端链表)。 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的编码有二种 ziplist编码 或者 hashtable。

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

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

hashtable 编码是字典作为底层实现,字典的键是字符串对象,值则全部设置为 null。

Set的编码有二种intset 或者 hashtable。

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

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

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

有序集合的编码有二种 ziplist 或者 skiplist。

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

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相比不会消耗额外内存。

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带上,这样就是知道是哪些商品了。

高并发

单机的 Redis,能够承载的 QPS大概就在上万到几万不等。对于缓存来说,一般都是用来支撑读高并发的。因此架构做成主从(master-slave)架构,一主多从,主负责写,并且将数据复制到其它的slave 节点,从节点负责读。所有的读请求全部走从节点。这样也可以很轻松实现水平扩容,支撑读高并发。

高可用

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

哨兵机制

哨兵是一个分布式系统,你可以在一个架构中运行多个哨兵进程,这些进程使用流言协议来接收关于主节点是否下线的信息,并使用投票协议来决定是否执行自动故障迁移,以及选择哪个备节点作为新的主节点。每个哨兵会向其它哨兵、主节点、备节点定时发送消息,以确认对方是否"活"着,如果发现对方在指定时间(可配置)内未回应,则暂时认为对方已挂。
若"哨兵群"中的多数哨兵,都报告某一主节点没响应,系统才认为该主节点"彻底死亡",通过算法,从剩下的备节点中,选一台提升为主节点,然后自动修改相关配置。可以通过修改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有些命令复制度很高,复杂度过高的命令如下:

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缓存、本地应用缓存和分布式缓存等多种技术手段,实现了商品详情页的动态化和缓存优化,提高用户访问商品详情页的速度和体验。同时,通过开关前置化和缓存过期机制,确保了缓存数据的有效性,降低了对后端数据库的访问压力。

7.12.1主从同步和哨兵机制

主从复制通常采用异步方式,可能导致主节点数据尚未完全复制至从节点,主节点便已故障,导致数据丢失。因此,需要控制复制数据的时长和ACK延迟,降低数据丢失风险。

主从切换过程通常使用哨兵机制。但在主节点正常运行时,可能因与某从节点连接中断,哨兵误判主节点已故障。在此情况下,哨兵可能启动选举,将某从节点升级为主节点,导致集群出现两个主节点,发生脑裂。旧主节点恢复网络后,将被升级为从节点并挂载至新主节点,导致自身数据丢失,并需从新主节点复制数据。而新主节点并未包含后续客户端写入的数据,导致这些数据丢失。为降低数据丢失风险,可设置连接主节点最少的从节点数量和从节点连接主节点最大的延迟时间,若主节点与从节点断开连接,且从节点超过阈值时间未收到ACK消息,则拒绝客户端的写请求,将数据丢失控制在可控范围。

7.12.2多级缓存

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上做开关,设置缓存过期时间,当缓存数据过期时,强制从后端应用获取最新数据,并更新缓存。

7.12.3冷热分离

冷热分离的具体步骤:

(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)数据预热:优点是可以避免缓存热点重构的问题;缺点是需要占用更多的内存空间,同时需要在低访问时间段主动重构缓存。

总之,不同的技术解决方案和手段都有其优缺点。程序员需要根据实际情况选择适合自己的方案,并且不断地优化和改进,以提高系统的性能和稳定性。

4.深入理解消息中间件

解决过各种消息通讯场景的疑难问题,消息中间件(Kafka、RabbitMQ、RocketMQ)出现的消息可靠投递、消息丢失、消息顺序性、消息延迟、过期失效、消息队列满了、消息高可用等问题都有着不错的实战解决方案。有消息中间件调优经验,如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 出来。

5.深入理解开源框架

熟悉Spring中Bean的生命周期与线程安全、单例模式的单例Bean、Spring AOP底层实现原理、Spring循环依赖、Spring容器启动流程、Spring事务及传播机制底层原理、Spring IOC容器加载过程与依赖注入、Spring的自动装配、Spring6.0核心新特性;Spring Boot自动装配、Spring Boot启动过程、Spring Framework的SPI机制;SpringMVC执行流程;Dubbo服务发现与调用、Dubbo容错机制、Dubbo负载均衡、Dubbo序列化协议、动态感知服务下线;ZooKeeper选举、脑裂与假死、Zab协议、Quorum机制、ACL访问控制列表。深入理解@Configuration、@Autowired、@Resource、@ComponentScan、@Conditional、@Lazy、@Primary、@Import、@SpringBootApplication等注解的底层实现。

Spring Bean的生命周期

Spring Bean的生命周期是指在应用程序运行过程中,从创建到销毁的整个过程,包括Bean的实例化、依赖注入、初始化、销毁等多个阶段。在这个过程中,Spring框架会调用多个方法和事件来完成Bean的创建和处理,这个过程非常重要,对于了解Spring框架的工作原理和优化Bean的性能非常有帮助。下面我们来详细介绍Spring Bean的生命周期。

  1. 实例化 - Instantiation

在Spring容器中,当我们在配置文件中声明一个Bean的时候,Spring会通过反射机制实例化一个Bean对象,这个对象被称为原型对象。实例化的过程中,Spring通过构造函数或者工厂方法来创建Bean实例。构造函数注入和工厂方法注入都是在这一步完成的。

  1. 属性设置 - Populate properties

在实例化完成后,Spring会自动为Bean注入属性。这个过程称为属性设置。Spring使用反射机制或者Setter方法来注入属性。我们可以通过在Bean定义xml中配置标签来设置属性值。

  1. 初始化前 - Initialization callback

初始化前阶段是为Bean做一些初始化的准备工作。在这个阶段,Spring会检查是否有实现了InitializingBean接口的Bean。如果实现了,就会在这个时候调用Bean中的afterPropertiesSet方法。还有一种方式可以在xml文件中声明init-method属性来指定Bean的初始化方法,当Bean被实例化之后会自动调用。

  1. 初始化后 - Post-initialization callback

初始化后阶段是Bean的最后一次变更,一旦进入此阶段,Bean就已经完成了初始化。在这个阶段,Spring会检查是否有实现了BeanPostProcessor接口的类。如果有,就会在这个时候调用postProcessBeforeInitialization方法来进行额外的初始化操作。这可以用来拦截Bean的初始化,对Bean进行修改或封装。

  1. 初始化完成 - Initialization finished

初始化完成阶段是Bean的最后一个阶段,这个时候Bean已经被完全初始化了。这个时候,Bean就已经可以被容器使用了。

  1. Bean销毁 - Destruction

当容器需要销毁一个Bean时,会先检查Bean是否实现了DisposableBean接口。如果实现了,就会调用Bean中的destroy方法。还有一种方式可以在xml文件中声明destroy-method属性来指定Bean的销毁方法,当Bean被销毁之前会自动调用。

举个例子:假设你是一位追求完美的花艺师。你在春天的花展上,带来了你独特的艺术作品。这次,你决定采用Spring Bean来打造花展中的一个展位。

首先,你需要在花展中找到一个空间。这个空间就相当于Spring容器中的配置文件。你需要在配置文件中声明Bean,并为它们设置属性和依赖关系,这些都是在属性设置阶段完成的。在这个过程中,你会逐步构建出一个原型对象。

接下来,你需要把所有花束放在花架上。这就相当于初始化前阶段,你会在这个阶段进行一些准备工作,比如检查花束的数量、清洁花束、调整花束的位置等。在这个阶段,你也可以使用实现了InitializingBean接口的类,来执行一些自定义的初始化操作。

初始化后阶段就像你在花展前调整花束的最后一次机会。你可以使用实现了BeanPostProcessor接口的类,来拦截初始化过程,对花束进行修改或封装。在一切准备就绪后,你的花束就已经完全初始化了。

展示过程中,你需要不断调整花束的状态和位置,以确保它们始终保持最佳状态。这就相当于Bean的生命周期中的实例化和初始化阶段。在这个过程中,你可以使用反射机制或者Setter方法来注入属性,并使用xml文件中声明的init-method属性来指定初始化方法。

当所有展示结束后,你需要开始收拾花束,并将它们妥善地保存。这就相当于Bean的销毁阶段。在这个阶段,你可以使用实现了DisposableBean接口的类,来执行一些自定义的销毁操作。你也可以使用xml文件中声明的destroy-method属性来指定销毁方法。

总之,Spring Bean的生命周期就像是展示你的花艺作品一样。你需要在花展中找到空间、搭建花架、放置花束、调整花束的位置和状态、收拾花束和花架,这个过程非常重要,对于你的花艺作品和Spring应用的性能都至关重要。

Spring Bean线程安全

Spring Bean 的线程安全性是指在多线程环境下使用 Bean 时,Bean 是否能够正确地执行其预期操作。当多个线程同时访问同一个 Bean 实例时,可能会导致线程安全问题,例如竞争条件、死锁等等。因此,确保 Spring Bean 的线程安全性非常重要,尤其是在高并发应用程序中。

Spring Framework 通过提供不同的 Bean 作用域来解决线程安全问题。作用域范围为 singleton 的 Bean 实例是线程不安全的,因为单个实例将在整个应用程序中共享。如果多个线程同时访问相同的 singleton Bean 实例,则可能会发生竞争条件和数据损坏。相反,作用域范围为 prototype 的 Bean 实例是线程安全的,因为每次请求 Bean 都会创建新的实例。

在 Spring 框架中,还提供了其他作用域范围,例如 request、session 和 global session 等等。这些范围意味着在请求、会话或全局会话期间创建的 Bean 实例仅由与该请求、会话或全局会话相关联的线程使用。因此,它们是线程安全的。

需要注意的是,即使在作用域范围为 prototype 的 Bean 实例中,如果 Bean 的依赖项是线程不安全的,则仍可能会出现线程安全问题。因此,在涉及到 Bean 的依赖项时,确保它们也是线程安全的。

Spring Bean 的实现原理

理解 Spring Bean 的线程安全性需要深入了解其实现原理。在 Spring Framework 中,Bean 实例是通过 BeanFactory 或 ApplicationContext 接口来创建和管理的。BeanFactory 是 Spring Framework 的核心接口,它提供了创建、配置和管理 Bean 实例的方法。ApplicationContext 是 BeanFactory 接口的子接口,它提供了更高级别的特性,例如事件发布、国际化和各种应用程序层次结构上下文。

当应用程序启动时,Spring 容器将读取并解析 ApplicationContext 或 BeanFactory 配置,并创建和初始化所有标记为 Bean 的类。在创建 Bean 时,Spring 容器将根据 Bean 的作用域范围来决定创建新实例还是返回现有实例。如果 Bean 的作用域范围为 singleton,Spring 容器将创建一个实例并在整个应用程序中共享该实例。如果 Bean 的作用域范围为 prototype,则每次调用该 Bean 时都会创建一个新实例。无论哪种作用域,都可以在 Bean 的定义中指定。

Spring Bean 的线程安全性取决于其作用域范围以及其依赖项的线程安全性。如果 Bean 的作用域范围为 prototype,则每个线程将拥有自己的 Bean 实例,并且不会相互干扰,因此是线程安全的。如果 Bean 的作用域范围为 singleton,并且 Bean 的依赖项是线程安全的,则 Bean 也是线程安全的。如果 Bean 的依赖项是线程不安全的,则该 Bean 在多线程环境中可能会存在线程安全问题,即使是 prototype 作用域的 Bean。

在 Spring Framework 的实现中,Bean 实例是由对象工厂创建的。这些对象工厂实际上是 Spring 容器的基本组成部分,可以通过 BeanFactory 或 ApplicationContext 接口访问。对象工厂是一个工厂模式,它封装了对象的实际创建过程。在创建 Bean 实例时,对象工厂将使用 BeanDefinition 和 BeanWrapper 对象来指示如何创建和管理 Bean 实例。BeanDefinition 包含 Bean 的元数据信息,例如名称、作用域、类名、构造函数参数、属性、依赖项等等。BeanWrapper 是一个包装器对象,用于访问和操作 Bean 实例的属性。

Spring Bean 的线程安全性问题解决方案

为了解决 Spring Bean 的线程安全问题,可以采用以下几种方法:

1. 使用线程安全 Bean

Spring Framework 中有许多线程安全的 Bean 实现,例如 ConcurrentHashMap、ConcurrentLinkedQueue、AtomicInteger 等等。如果可以使用线程安全的 Bean 实现来代替非线程安全的实现,可以有效地解决线程安全问题。

2. 对 Bean 进行同步

对 Bean 进行同步是解决线程安全问题的另一种方法。可以使用 synchronized 关键字对 Bean 的方法进行同步,以确保只有一个线程可以访问该方法。但这种方法会导致性能问题,并且需要仔细设计同步块,以避免死锁等问题。

3. 将 Bean 的作用域范围设为 prototype

将 Bean 的作用域范围设置为 prototype 是一种解决线程安全问题的简单方法。由于每次请求 Bean 时都会创建新实例,因此不存在线程安全问题。但是,由于每次请求 Bean 都会创建新实例,这会导致性能问题,并且可能导致内存泄漏。

4. 使用 AOP 实现线程安全性

使用 AOP(面向切面编程)可以轻松地解决 Spring Bean 的线程安全问题。可以使用 AOP 拦截器来确保 Bean 的方法只由一个线程访问。可以使用 Spring Framework 的 @AspectJ 注释来创建拦截器,并指定拦截器适用于哪些 Bean 和方法。

举例说明

想象一下你正在经营一家快餐店,并且有很多顾客同时来到店里。如果你的服务员只有一张厨房订单,那么他们可能会在交叉的订单上工作,导致混乱和错误。为了确保订单正确无误,你决定让每个服务员都有自己的订单本子,他们可以在上面记录每个顾客的点餐内容。这样,即使有多个服务员同时处理订单,他们也不会相互干扰。

这就好比 Spring Bean 的线程安全问题。当多个线程同时访问同一个 Bean 实例时,可能会导致线程安全问题,例如竞争条件、死锁等等。为了解决这个问题,Spring Framework 通过提供不同的 Bean 作用域来保证线程安全。作用域为 singleton 的 Bean 实例是线程不安全的,因为单个实例将在整个应用程序中共享。如果多个线程同时访问相同的 singleton Bean 实例,则可能会发生冲突和数据损坏。因此,确保 Spring Bean 的线程安全性非常重要,尤其是在高并发应用程序中。

Spring Framework 中使用对象工厂创建 Bean 实例,并通过 BeanDefinition 和 BeanWrapper 对象来指示如何创建和管理 Bean 实例。其中,BeanDefinition 包含 Bean 的元数据信息,例如名称、作用域、类名、构造函数参数、属性、依赖项等等;BeanWrapper 是一个包装器对象,用于访问和操作 Bean 实例的属性。

如果 Bean 的作用域范围为 prototype,则每次请求 Bean 时都会创建新实例,因此不存在线程安全问题。但是,由于每次请求 Bean 都会创建新实例,这会导致性能问题,并且可能导致内存泄漏。因此,Spring Framework 中还提供了其他作用域范围,例如 request、session 和 global session 等等。这些范围意味着在请求、会话或全局会话期间创建的 Bean 实例仅由与该请求、会话或全局会话相关联的线程使用,因此是线程安全的。

为了解决 Spring Bean 的线程安全问题,可以采用以下几种方法:使用线程安全 Bean、对 Bean 进行同步、将 Bean 的作用域范围设为 prototype 或使用 AOP 实现线程安全性。采用不同的方法取决于具体情况和需求,需要根据实际情况进行选择。

单例模式的单例Bean

单例模式是一种常见的设计模式,它的核心思想是确保在应用程序中只存在一个实例对象,以保证这个对象作为一个全局变量可以被访问和共享。在Spring框架中,单例模式被广泛应用,因为Spring要保证Bean的单例性,确保在整个应用程序中只有一个实例对象,这样可以提高性能并且保证数据的一致性。

在Spring中,单例模式的Bean是我们最常用的一个模式,它所扮演的角色是作为Spring容器中的一个重要组成部分,由Spring容器来管理和维护。下面我们就来详细地了解一下Spring中单例Bean的实现原理。

首先,我们需要知道Spring在启动时会将所有的Bean都加载到内存中,这些Bean包括单例Bean和非单例Bean。对于非单例Bean,Spring会在创建Bean的时候将其放入缓存中,当有请求需要使用这个Bean的时候,从缓存中取出即可。

而对于单例Bean,Spring会在容器启动的时候创建并初始化它们,然后将它们放入一个特殊的缓存中,这个缓存被称为"单例池"(Singleton Pool),这个单例池是全局唯一的,用于存放所有的单例Bean。

在Spring中,单例Bean的创建和初始化过程是由BeanFactory完成的。BeanFactory是Spring容器的核心接口,它的作用是管理Bean的生命周期和依赖关系。

当Spring容器启动时,BeanFactory会根据XML配置文件或注解扫描器来创建Bean对象,对于单例Bean,BeanFactory会在创建Bean的过程中检查单例池中是否已经存在该Bean对象,如果存在就直接返回,否则就创建新的对象并将其放入单例池中。

对于每一个单例Bean,Spring会为其创建一个代理对象,这个代理对象被称为"早期Bean(Early Bean)",它的作用是在单例Bean还没有被完全初始化之前提供一些基础的功能,例如依赖注入、AOP切面等。然后,Spring会按照Bean依赖关系的顺序逐个完成单例Bean的初始化工作,直到所有的单例Bean都被初始化完成。

在单例Bean的生命周期中,如果出现了一些异常情况,例如Bean的构造器出现了错误,Spring会将该Bean从单例池中移除,避免出现后续错误。

需要注意的是,虽然Spring中的单例Bean是线程安全的,但是如果这些单例Bean保存了共享状态,那么在并发场景下可能会出现问题。因此,我们应该避免在单例Bean中保存共享状态,并尽可能地采用无状态的方式来编写业务逻辑。

举个例子:假设你是一名游戏开发者,正在开发一款多人在线游戏。在游戏中,玩家需要共享很多数据,例如游戏关卡、角色等级和经验值等等。为了确保数据的一致性和节约内存空间,你需要使用单例模式来管理这些数据。

你想到了一个方法,那就是在游戏启动时创建一个GameManager类,并将其设置为单例模式。GameManager类中保存了游戏的全部数据,并提供了各种方法来对外暴露这些数据。在玩家进入游戏时,GameManager会通过网络从服务器上获取最新的数据,并更新本地数据。在玩家离开游戏时,GameManager会将本地数据上传到服务器上。

在Spring中,单例Bean的实现原理跟这个GameManager类有些相似。Spring会在启动时创建所有的单例Bean,并将其放入一个单例池中。当有请求需要使用某个单例Bean时,Spring会从单例池中获取它并返回给请求方。同时,Spring会为每个单例Bean创建一个代理对象,用于提供一些基础的功能,例如依赖注入和AOP切面。最后,Spring会按照Bean依赖关系的顺序逐个完成单例Bean的初始化工作。

需要注意的是,如果在单例Bean中保存了共享状态,可能会在并发场景下出现问题,因此应该尽可能地避免这种情况。在编写业务逻辑时,应该尽量采用无状态的方式,将状态保存在局部变量中,而不是保存在单例Bean中。

Spring AOP底层实现原理

Spring循环依赖

Spring容器启动流程

Spring事务及传播机制底层原理

Spring IOC容器加载过程与依赖注入

Spring的自动装配

Spring6.0核心新特性

Spring Boot自动装配

Spring Boot启动过程

Spring Framework的SPI机制

SpringMVC执行流程

Dubbo服务发现与调用

Dubbo容错机制

Dubbo负载均衡

Dubbo序列化协议

动态感知服务下线

ZooKeeper选举

脑裂与假死

Zab协议

Quorum机制

ACL访问控制列表

@Configuration、@Autowired、@Resource、@ComponentScan、@Conditional、@Lazy、@Primary、@Import、@SpringBootApplication注解的底层实现

6.深入理解ElasticSearch

核心语法、倒排索引、底层原理与分组聚合查询、具备集群高可用实战经验、集群架构原理。有ElasticSearch调优经验,如GC调优、索引优化设置、查询方面优化、数据结构优化、集群架构设计、慢查询优化、可用性优化、性能优化、执行引擎的优化、成本优化、扩展性优化、分析性能问题等。

7.熟练使用设计模式

不同营销策略的切换场景:策略模式; 对象的创建和管理场景:工厂模式; 奖励分配和活动参与场景:责任链模式;实时消息推送、互动交流场景:发布-订阅模式; 用户的行为响应和推送通知功能场景:观察者模式; 支付场景:策略模式 + 工厂模式 + 门面模式 + 单例模式; 业务投放场景:责任链模式; 平台积分红包发放场景:装饰者模式; 订单状态场景:状态模式+观察者模式; 开具增值税发票场景: 建造者模式 + 原型模式; 商品多级分类目录场景:组合模式+访问者模式; 记录核心审计日志场景: 模板方法模式; 查询ElasticSearch大量数据场景:迭代器模式;

8.抢购系统落地

以高并发、高性能、高可用的技术作为基础保障,重点突破库存与限购、防刷与风控、数据一致、热点隔离、动静分离、削峰填谷、数据兜底、限流与降级、流控与容灾等核心技术问题。抢购系统所涉及到的最核心的技术内容:

缓存设计:多级缓存与库存分割

分离策略:主从分离与动静分离

流量策略:负载均衡与加权处理

数据库优化:合理选择字段类型、索引设计、查询性能、分库分表(垂直拆分与水平拆分)

异步优化:系统异步化、缓存队列、缓冲队列

代码异步化处理:Servlet3异步化、使用MQ异步化、自定义异步化策略

使用多级缓存:本地缓存、Redis缓存、数据库缓存

缓存问题:缓存击穿、缓存穿透、缓存雪崩

合理使用锁:注意锁粒度、锁的获取与释放、锁超时

池化技术:线程池、连接池、对象池、缓冲区

SQL优化:尽量走索引、尽量减少关联查询、查询数据量尽量少

物理机极致优化:CPU模式优化、操作系统参数优化、套接字缓冲区优化、频繁接收大文件优化、网卡层面优化、TCP连接优化、Nginx优化、网关优化

单机Java进程极致优化:JVM优化、Tomcat优化、线程模型优化、Servlet3异步化、RPC框架调优、资源静态化、Vertx异步化

隔离策略:线程隔离、连接隔离、业务隔离、系统隔离、数据隔离、热点隔离(动态热点与静态热点)流量隔离、逻辑隔离、物理隔离

流量控制:预约设计、缓存设计、动态感知、把控参与人数、设置人数上限

削峰与限流:验证码、问答题、异步消息、分层过滤、服务网关限流、业务网关限流、应用层限流(线程池限流与API限流)

服务降级:读服务降级、写服务降级、简化系统功能、舍弃非核心功能、数据兜底

热点数据:读热点与写热点

服务容灾:同机房多部署、多机房部署、同城双活、异地多活

库存扣减设计:下单减库存、付款减库存、与扣减库存、库存扣减问题解决方案、秒杀系统扣减库存方案、Redis实现扣减库存、Redis+Lua解决超卖、Redis分割库存、商品维度限购、库存防超卖

限购规则:商品维度限购与个人维度限购

防刷策略:Nginx条件限流、Token机制防刷、布隆过滤器校验、黑名单机制

风控策略:完善用户画像、丰富业务场景、不断优化算法

可复用于任何需要支撑高并发、大流量的业务场景

9.工作经验

能独立或带领团队Java工程师成员完成服务端代码的研发工作,结合业务需求给出合理的技术解决方案,改进现有模块功能,提高系统的可扩展性,封装性,稳定性。深入挖掘业务需求,可0-1设计高可用、高并发、高伸缩的分布式项目架构,环境搭建、自动化部署、服务器环境线上排查、性能评估相关经验。拥有产品需求讨论、项目开发计划制定、控制项目风险、开发团队组建、技术小组日常管理、进度检验、成本管理、开发部署问题梳理、任务分配、负责指导、培训普通开发工程师、代码Review、审核开发工程师的设计与研发质量等经验。

10.项目经验

项目的业务背景、解决的问题、实现的效果和带来的价值(提高了公司效率、降低成本、增加收益)、在项目中的角色和能力、项目的整体架构和技术栈(使用的框架、数据库、服务器、使用什么设计模式、优化技巧、性能调优)、自己在项目中的角色和贡献(参与的模块、负责的功能、解决的问题、在项目中的领导能力、带领团队完成项目、项目管理经验)、给出真实的数据和指标(使用的数据量、用户量、处理速度、项目的规模)、项目的演示链接和作品代码演示链接。
读者大大们不要着急,这篇文章估计要写个一周左右,我会持续更新,提前发出来,想看看读者朋友们有什么好的建议给到我

相关推荐
qq_4419960521 分钟前
Mybatis官方生成器使用示例
java·mybatis
巨大八爪鱼27 分钟前
XP系统下用mod_jk 1.2.40整合apache2.2.16和tomcat 6.0.29,让apache可以同时访问php和jsp页面
java·tomcat·apache·mod_jk
码上一元2 小时前
SpringBoot自动装配原理解析
java·spring boot·后端
计算机-秋大田2 小时前
基于微信小程序的养老院管理系统的设计与实现,LW+源码+讲解
java·spring boot·微信小程序·小程序·vue
魔道不误砍柴功4 小时前
简单叙述 Spring Boot 启动过程
java·数据库·spring boot
失落的香蕉4 小时前
C语言串讲-2之指针和结构体
java·c语言·开发语言
枫叶_v4 小时前
【SpringBoot】22 Txt、Csv文件的读取和写入
java·spring boot·后端
wclass-zhengge4 小时前
SpringCloud篇(配置中心 - Nacos)
java·spring·spring cloud
路在脚下@4 小时前
Springboot 的Servlet Web 应用、响应式 Web 应用(Reactive)以及非 Web 应用(None)的特点和适用场景
java·spring boot·servlet
黑马师兄4 小时前
SpringBoot
java·spring