文章目录
- 🌟 1.具备扎实的Java基础
- 🌟 2.深入理解MySQL关系型数据库
- 🌟 3.深入理解Redis缓存
- 🌟 4.深入理解消息中间件
- 🌟 5.深入理解开源框架
- 🍊 Spring Bean的生命周期
- 🍊 Spring Bean线程安全
- 🍊 单例模式的单例Bean
- 🍊 Spring AOP底层实现原理
- 🍊 Spring循环依赖
- 🍊 Spring容器启动流程
- 🍊 Spring事务及传播机制底层原理
- 🍊 Spring传播机制底层原理
- 🍊 Spring IOC容器加载过程
- 🍊 Spring依赖注入
- 🍊 Spring的自动装配
- 🍊 Spring6.0核心新特性
- 🍊 Spring Boot自动装配
- 🍊 Spring Framework的SPI机制
- 🍊 Spring Boot启动过程
- 🍊 SpringMVC执行流程
- 🍊 Dubbo服务发现与调用
- 🍊 Dubbo容错机制
- 🍊 Dubbo负载均衡
- 🍊 Dubbo序列化协议
- 🍊 Dubbo动态感知服务下线
- 🍊 ZooKeeper选举
- 🍊 ZooKeeper脑裂与假死
- 🍊 ZooKeeper的Zab协议
- 🍊 ZooKeeper的选举时间过长
- 🍊 ZooKeeper的Quorum机制
- 🍊 ZooKeeper的ACL访问控制列表
- 🍊 @Configuration、@Autowired、@Resource、@ComponentScan、@Conditional、@Lazy、@Primary、@Import、@SpringBootApplication注解的底层实现
- 🌟 6.深入理解ElasticSearch
- 🌟 7.熟练使用设计模式
- 🌟 8.抢购系统落地
- 🌟 9.工作经验
- 🌟 10.项目经验
📕我是廖志伟,一名Java开发工程师、Java领域优质创作者、CSDN博客专家、51CTO专家博主、阿里云专家博主、清华大学出版社签约作者、产品软文创造者、技术文章评审老师、问卷调查设计师、个人社区创始人、开源项目贡献者。跑过十五公里、徒步爬过衡山、有过三个月减肥20斤的经历、是个喜欢躺平的狠人。
📘拥有多年一线研发和团队管理经验,研究过主流框架的底层源码(Spring、SpringBoot、Spring MVC、SpringCould、Mybatis、Dubbo、Zookeeper),消息中间件底层架构原理(RabbitMQ、RockerMQ、Kafka)、Redis缓存、MySQL关系型数据库、 ElasticSearch全文搜索、MongoDB非关系型数据库、Apache ShardingSphere分库分表读写分离、设计模式、领域驱动DDD、Kubernetes容器编排等。有从0到1的高并发项目经验,利用弹性伸缩、负载均衡、报警任务、自启动脚本,最高压测过200台机器,有着丰富的项目调优经验。
📙在CSDN创作了上千篇文章后,和清华大学出版社签约四本书籍,于明年陆续出版⚡《Java项目实战—深入理解大型互联网企业通用技术》⚡基础篇、进阶篇、架构篇、📚《解密程序员的思维密码–沟通、演讲、思考的实践》📚,具体出版计划根据实际情况调整,希望各位读者大大到时多多支持。
💂博客主页: 我是廖志伟 | 👉开源项目:java_wxid | 🌥哔哩哔哩:我是廖志伟 | 🔖个人微信号: SeniorRD
🤟 希望各位读者大大多多支持用心写文章的博主,现在时代变了,信息爆炸,酒香也怕巷子深,博主真的需要大家的帮助才能在这片海洋中继续发光发热,所以,赶紧动动你的小手,点波关注❤️,点波赞👍,点波收藏⭐,甚至点波评论✍️,都是对博主最好的支持和鼓励!如需转载或搬运文章,请私信我哈。
🍋今天是2023年10月14日,愿你在新的一周里能够保持专业素养,成为技术领域的佼佼者!
🌟 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中内存管理的重要概念。
- 强引用是最为常见的引用类型,是指存在一个对象的引用,它会防止对象被垃圾回收器回收。即使内存不足时,JVM也不会回收被强引用引用的对象,除非该对象的引用被明确地赋值为null。
Object obj = new Object();
// obj是一个强引用
- 软引用是比较常用的引用类型之一,它用于描述一些还有用但并非必需的对象,软引用通常用于缓存数据,当内存不足时,JVM可以回收软引用的对象,从而释放缓存空间。当JVM需要内存时,会先回收这些软引用,如果空间仍然不足,才会抛出OOM异常。可以通过SoftReference类来实现软引用。
SoftReference<Object> softRef = new SoftReference<>(new Object());
// softRef是一个软引用
- 弱引用与软引用类似,它也是用于描述一些还有用但并非必需的对象,但是与软引用不同,弱引用被回收的时机更加快速,我们可以使用弱引用来实现一些临时性的对象,比如缓存中的某些对象,当不再需要这些对象时,JVM会自动回收它们。在垃圾回收时,只要发现存在弱引用引用的对象,就会被回收。可以通过WeakReference类来实现弱引用。
WeakReference<Object> weakRef = new WeakReference<>(new Object());
// weakRef是一个弱引用
- 虚引用是最为特殊的引用类型,它与前面的三种引用类型不同,虚引用并不会影响对象的生命期,而是用于在对象被回收时收到一个系统通知,可以实现资源的释放,比如文件句柄、网络连接等,如果我们直接使用强引用进行管理,容易出现资源泄露的问题。而使用虚引用则可以避免这个问题,因为虚引用在对象被回收时,会收到一个通知,然后程序可以在收到通知之后及时地释放资源。这样,程序员可以在对象被回收时进行一些清理操作。虚引用必须与ReferenceQueue(虚引用队列)一起使用。
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;
发现可以更新成功。
如果没有为b列添加索引,执行上述UPDATE语句可能会导致InnoDB使用表锁而不是行锁。
🎉 表锁的表现
将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中的一种特殊锁机制,用于保证事务的隔离性。
假设有这样一张表:
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的那个间隙,如下图所示:
| TX A | | TX B |
|--------------------+---------+--------------------|
|id=1 (Gap Lock) | |id=2 |
事务B现在要查询id为2的记录,但由于id为2的间隙被事务A锁定,所以事务B需要等待事务A提交或回滚才能进行查询,如下图:
| 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死锁面试相关问题及其答案:
- 什么是MySQL死锁?
MySQL死锁是指两个或多个事务都在等待对方持有的锁,导致它们都无法继续执行的情况。
- MySQL如何检测到死锁?
MySQL会不定期地进行死锁检测,如果检测到死锁,会把其中一个事务终止,并回滚该事务执行的操作。
- 如何避免MySQL死锁?
避免MySQL死锁的方法主要有三种:
(1)通过减少交叉事务的数量来降低死锁发生的概率;
(2)通过加锁时的顺序来避免死锁;
(3)通过增加超时时间来解决死锁。
- 如何解决MySQL死锁?
解决MySQL死锁的方法主要有两种:
(1)终止其中一个事务并回滚该事务执行的操作;
(2)调整事务执行的顺序来避免死锁。
- 如何排查MySQL死锁?
排查MySQL死锁的方法主要有两种:
(1)查看MySQL日志,找到死锁发生的时间和事务ID;
(2)使用SHOW ENGINE INNODB STATUS命令查看当前正在运行和等待的事务信息,并分析死锁情况。
总之,MySQL死锁是MySQL数据库中常见的问题之一,对此需要进行深入的了解和掌握,以避免和解决这种情况。
🍊 原子性底层实现原理(undo log日志 )
原子性是指一个操作要么全部执行成功,要么全部执行失败,不存在部分执行的情况。在数据库底层实现中,为了保证事务的原子性,通常采用undo log日志来实现原子性,记录事务执行前的数据状态,以便在发生错误或者回滚时恢复数据原始状态。Undo log日志记录了数据库操作的所有细节,包括修改的数据和修改前的值。
具体的实现原理如下:
- 在事务执行之前,先将需要修改的数据放入buffer pool中的内存页中,并将修改前的数据复制一份放入undo log中。
- 事务执行时,将对应的数据进行修改,并将修改后的数据记录到redo log中。
- 如果事务执行成功,则将redo log中的数据更新到磁盘上的数据文件中。同时将undo log中的数据删除或者标记为已提交。
- 如果事务执行失败或者需要回滚,则从undo log中读取修改前的数据,恢复原始数据状态。
举个例子:假设用户要将某个账户的余额从100元增加到200元,系统执行以下步骤:
- 记录当前余额为100元。
- 在缓存区中将余额增加到200元。
- 等待操作确认。
- 操作确认,提交缓存区中的数据。
- 完成。
如果在第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缓存机制可以提高数据库查询效率,但也有一些弊端:
-
内存使用过多:BufferPool缓存机制需要占用一定的内存空间,当数据库中数据量非常大时,会占用大量的内存空间,可能会导致系统内存不足。
-
热数据的维护:BufferPool缓存机制只能缓存最近访问的数据,对于长时间不访问或很少访问的数据,缓存效果并不理想,需要花费额外的时间和资源从磁盘中读取。
-
数据的一致性:如果缓存中的数据与磁盘上的数据不一致,可能会导致数据丢失或数据不一致的情况发生。
-
磁盘IO操作的影响:如果缓存中的数据过多,可能会影响磁盘IO操作的效率,导致系统负载增加,从而影响数据库的查询效率。
-
缓存命中率的限制:BufferPool缓存机制只能缓存一部分数据,无法保证所有的查询都能从缓存中获取数据,如果缓存命中率不高,查询效率仍然会受到影响。
🍊 主键自增长实现原理
MySQL 主键自增长的实现原理,其实涉及到数据库设计和计算机科学中的自动编号机制。通俗来讲,就是数据库在新增一条记录时,可以自动为该记录生成唯一的标识符,也就是主键值。下面我们来详细解释这个过程。
🎉 1. 定义自增长主键
在 MySQL 中定义一个自增长主键,需要使用 AUTO_INCREMENT 关键词。例如:
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 主表创建子表
/**
* {
* "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;
}
主表字段同步到子表,代码如下:
主表字段同步到子表
/**
* 主表字段同步到子表
* @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;
}
主表索引同步子表,代码如下:
主表索引同步子表
/**
* 主表索引同步子表
* @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
<!--校验子表是否存在 这里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进行更新操作,那么会出现:
- 线程A更新了数据库;
- 线程B更新了数据库;
- 线程B更新了缓存;
- 线程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毫秒,记录慢日志
CONFIG SET slowlog-log-slower-than 10000
只保留最近1000条慢日志
CONFIG SET slowlog-max-len 1000
后面再通过SLOWLOG get [n]
查看。
对于大key可以通过以下命令直接以类型展示出来,它只显示元素最多的key,但不代表占用内存最多,命令如下:
#-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系统)文件里面修改以下配置实现:
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及以上版本可以开启碎片自动整理解决,只不过碎片整理是在主线程中完成的,通用先对延时范围和时间进行评估,然后在机器负载不高同时业务不繁忙时开启内存碎片整理,避免影响客户端请求。
开启内存自动碎片整理配置如下:
# 已启用活动碎片整理
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的整个生命周期,它分为四个阶段:实例化、属性赋值、初始化和销毁。
实例化通过构造器实例化和工厂方法实例化两种方式实现;构造器实例化是指通过Java类的构造函数实例化Bean。在Spring中,构造函数可以是无参构造函数,也可以是有参构造函数。Spring通过利用Java反射机制,调用bean对应类的构造方法进行实例化。在XML文件中,可以使用标签的class属性指定要实例化的Bean类。当容器启动时,容器会根据class属性的全限定类名使用反射机制实例化Bean。在注解方式中,@Component、@Service、@Controller等注解都是用来标注Bean类,容器会根据这些注解的信息进行实例化。工厂方法实例化是指通过Java类的静态方法实例化Bean,实现方式类似于单例模式的实现方式。在Spring中,通过配置工厂方法的返回值和参数,实现Bean的实例化。
实例化前后的InstantiationAwareBeanPostProcessor接口是Spring框架中的一个扩展点,例如SmartInstantiationAwareBeanPostProcessor接口主要作用是在Bean实例化之前,提供对Bean实例化的更细粒度的控制,并提供给实现者对AOP代理和"热插拔"类等功能的支持,通过该接口,我们可以在Bean实例化之前完成对Bean实例化的"拦截",并加入自己的处理逻辑。在Bean实例化之前,这些接口可以用来修改Bean的实例化过程,或者进行Bean类型转换,AOP代理等相关操作;在Bean实例化后,这些接口可以用来修改Bean的属性值、进行Bean的修饰、或者完成其他需要在Bean实例化后执行的操作。它提供了在Bean实例化过程中的多个扩展点,在实际应用中,InstantiationAwareBeanPostProcessor接口可以用于实现很多的扩展功能。例如:AOP技术,可以通过实现该接口来在Bean实例化和属性设置过程中进行代理和增强操作。数据库连接池技术,可以通过实现该接口来在Bean实例化过程中进行数据库连接和释放操作。外部配置文件加载,可以通过实现该接口来在Bean实例化过程中加载外部配置文件,并将其设置到Bean实例中。
属性赋值是Spring Bean生命周期的第二个阶段,它是通过BeanPostProcessor接口实现的。BeanPostProcessor在实例化Bean后,对Bean进行属性赋值。属性赋值可以通过XML文件或注解方式进行配置,在XML文件中,可以使用标签或标签进行属性赋值。Spring容器在实例化Bean后,会遍历所有的BeanPostProcessor实现类,调用它们的postProcessBeforeInitialization()方法,进行属性赋值操作。这个方法的返回值是一个对象,可以修改或替换原始的Bean实例。在注解方式中,可以使用@Autowired或@Value注解进行属性赋值。这些注解的实现原理也是基于BeanPostProcessor接口实现的。
属性注入之后,开始执行Aware,Spring框架提供了Aware相关接口,如BeanNameAware、BeanFactoryAware、ApplicationContextAware等,通过实现 BeanNameAware接口,Bean可以获取到Spring容器中创建的Bean的名称,可以在Bean中直接调用该名称对应的Bean。通过实现BeanFactoryAware接口,Bean可以获取到Spring容器中的BeanFactory,可以通过BeanFactory获取其他Bean的实例。通过实现ApplicationContextAware接口,Bean可以获取到Spring容器的ApplicationContext,可以通过ApplicationContext获取其他Bean的实例和容器中的其他资源。
初始化是Spring Bean生命周期的第三个阶段,它包括两个过程:初始化前和初始化后。Spring提供了一个扩展点BeanPostProcessor,BeanPostProcessor是在Bean的创建过程中,在执行初始化方法之前和之后的扩展点,它定义了两个方法:postProcessBeforeInitialization()和postProcessAfterInitialization()。
postProcessBeforeInitialization()方法在执行Bean的初始化方法之前被调用,可以对Bean进行自定义的初始化操作。例如,可以修改Bean的属性值、增加一些代理逻辑等等。此时,Bean还没有执行初始化方法,也就是说Bean还没有完全初始化。这个方法常常用于注册一些事件监听器、给Bean进行数据校验等。
实现InitializingBean接口是一种在Spring框架中初始化Bean的方式,这种方式要求Bean实现InitializingBean接口,并且实现afterPropertiesSet()方法,在该方法中执行Bean的初始化操作。Spring容器在创建Bean实例之后,会自动调用afterPropertiesSet()方法完成Bean的初始化。除了实现InitializingBean接口,还可以通过@Bean注解中的initMethod属性,或者在XML配置文件中使用元素的init-method属性来指定Bean的初始化方法。
postProcessAfterInitialization()方法在执行Bean的初始化方法之后被调用,可以对Bean进行自定义的后处理操作。例如,可以对Bean做一些额外的检查、修改某些属性值等等。此时,Bean已经执行了初始化方法,并且已经完全初始化。这个方法常常用于增强Bean的能力或者为Bean提供一些额外服务(如数据缓存、资源池等)。
销毁是Spring Bean生命周期的最后一个阶段,它是通过实现DisposableBean接口或通过配置destroy-method方法来实现。实现DisposableBean接口,需要实现destroy()方法,该方法会在Bean销毁前被调用。在容器关闭之前,Spring会先销毁Bean,并回调Bean的destroy()方法。在XML文件中,可以使用destroy-method属性指定Bean的销毁方法。Spring容器会在销毁Bean之前调用这个方法。通过实现DisposableBean接口,Bean类可以在调用destroy()方法之前实现销毁操作。该方法会在Bean销毁之前调用。销毁的具体过程可以自定义实现。在销毁Bean之前,需要先关闭应用上下文,释放Bean占用的资源。
举个例子:假设你是一位追求完美的花艺师。你在春天的花展上,带来了你独特的艺术作品。这次,你决定采用Spring Bean来打造花展中的一个展位。
首先,你需要在花展中找到一个空间。这个空间就相当于Spring容器中的配置文件。你需要在配置文件中声明Bean,并为它们设置属性和依赖关系,这些都是在属性设置阶段完成的。在这个过程中,你会逐步构建出一个原型对象。
接下来,你需要把所有花束放在花架上。这就相当于初始化前阶段,你会在这个阶段进行一些准备工作,比如检查花束的数量、清洁花束、调整花束的位置等。在这个阶段,你也可以使用实现了InitializingBean接口的类,来执行一些自定义的初始化操作。
初始化后阶段就像你在花展前调整花束的最后一次机会。你可以使用实现了BeanPostProcessor接口的类,来拦截初始化过程,对花束进行修改或封装。在一切准备就绪后,你的花束就已经完全初始化了。
展示过程中,你需要不断调整花束的状态和位置,以确保它们始终保持最佳状态。这就相当于Bean的生命周期中的实例化和初始化阶段。在这个过程中,你可以使用反射机制或者Setter方法来注入属性,并使用xml文件中声明的init-method属性来指定初始化方法。
当所有展示结束后,你需要开始收拾花束,并将它们妥善地保存。这就相当于Bean的销毁阶段。在这个阶段,你可以使用实现了DisposableBean接口的类,来执行一些自定义的销毁操作。你也可以使用xml文件中声明的destroy-method属性来指定销毁方法。
总之,Spring Bean的生命周期就像是展示你的花艺作品一样。你需要在花展中找到空间、搭建花架、放置花束、调整花束的位置和状态、收拾花束和花架,这个过程非常重要,对于你的花艺作品和Spring应用的性能都至关重要。
🍊 Spring Bean线程安全
解释线程安全:首先,需要解释什么是线程安全。线程安全是多线程环境下保证数据安全的一种策略。在Spring中,Bean的线程安全是指确保多个线程可以共享一个Bean实例,而不会导致数据不一致或其他线程安全问题。在Spring框架中,由于默认的Bean定义是单例的,所以如果不进行特殊处理,Bean的生命周期方法,包括初始化方法和销毁方法,可能在不同线程中同时执行,这就存在线程安全问题。
Bean的作用域:Bean的作用域是影响线程安全的一个重要因素。在Spring框架中,默认的Bean作用域是单例的,这意味着所有线程共享一个Bean实例。如果Bean是有状态的,也就是它保存了数据,多个线程可能同时访问和修改它的状态,这就可能导致线程安全问题。
解决方法:为了解决Spring Bean的线程安全问题,可以采用以下方法。首先,可以将Bean的作用域设置为原型(prototype),这样每次获取Bean时都会创建一个新的实例,避免多个线程共享一个实例。其次,可以在Bean的生命周期方法上使用同步注解(@Synchronized),确保在同一时间只有一个线程执行这些方法,避免并发访问和修改状态。此外,还可以使用代理模式来包装Bean,通过代理的调用保证线程安全。而且Spring框架本身提供了很多线程安全的策略,例如使用ThreadLocal变量来存储线程的私有数据,避免数据竞争。也可以使用 AOP 拦截器来确保 Bean 的方法只由一个线程访问。可以使用 Spring Framework 的 @AspectJ 注释来创建拦截器,并指定拦截器适用于哪些 Bean 和方法。
安全性评估:最后,需要评估Spring Bean的线程安全性。对于无状态的Bean,由于没有数据存储功能,所以多个线程共享一个实例也不会产生线程安全问题。对于有状态的Bean,如果多个线程同时访问和修改其状态,就可能产生线程安全问题,需要采取相应的措施进行处理。
想象一下你正在经营一家快餐店,并且有很多顾客同时来到店里。如果你的服务员只有一张厨房订单,那么他们可能会在交叉的订单上工作,导致混乱和错误。为了确保订单正确无误,你决定让每个服务员都有自己的订单本子,他们可以在上面记录每个顾客的点餐内容。这样,即使有多个服务员同时处理订单,他们也不会相互干扰。
这就好比 Spring Bean 的线程安全问题。当多个线程同时访问同一个 Bean 实例时,可能会导致线程安全问题,例如竞争条件、死锁等等。为了解决这个问题,Spring Framework 通过提供不同的 Bean 作用域来保证线程安全。作用域为 singleton 的 Bean 实例是线程不安全的,因为单个实例将在整个应用程序中共享。如果多个线程同时访问相同的 singleton Bean 实例,则可能会发生冲突和数据损坏。
🎉 Spring Bean 的实现原理
Spring Bean的实现原理是通过IoC容器来控制对象的实例化和生命周期,并通过依赖注入来降低应用程序的耦合度。
在 Spring Framework 中,Bean 实例是通过 BeanFactory 或 ApplicationContext 接口来创建和管理的。BeanFactory 是 Spring Framework 的核心接口,它提供了创建、配置和管理 Bean 实例的方法。ApplicationContext 是 BeanFactory 接口的子接口,它提供了更高级别的特性,例如事件发布、国际化和各种应用程序层次结构上下文。
当应用程序启动时,Spring 容器将读取并解析 ApplicationContext 或 BeanFactory 配置,并创建和初始化所有标记为 Bean 的类。在创建 Bean 时,Spring 容器将根据 Bean 的作用域范围来决定创建新实例还是返回现有实例。如果 Bean 的作用域范围为单例模式,Spring 容器将创建一个实例并在整个应用程序中共享该实例。如果 Bean 的作用域范围为原型模式,则每次调用该 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 实例的属性。
🍊 单例模式的单例Bean
单例模式的概念:单例模式是一种设计模式,它保证在一个应用程序中只有一个实例存在。这个实例可以被全局访问,但是不能被实例化多次。
单例Bean的定义:在Spring框架中,单例Bean是指作用域为singleton的Bean。这意味着在Spring IoC容器中,只有一个Bean的实例被创建,并且这个实例可以被所有的Bean和应用程序访问。
单例Bean的实现方式:在Spring中,单例Bean的实现方式是通过在配置文件中指定Bean的作用域为singleton。当IoC容器创建一个Bean时,它会检查该Bean的作用域是否为singleton。如果是,IoC容器只会创建一个Bean实例,并将该实例保存到缓存中,以便后续的请求可以直接访问该实例。
单例Bean的生命周期:单例Bean的生命周期包括三个阶段:实例化阶段、属性赋值阶段、初始化阶段和销毁阶段。这些阶段可以通过相应的回调方法来实现,例如构造器、配置器、初始化器和销毁器。
单例Bean的线程安全问题:由于单例Bean只有一个实例,因此在多线程环境下可能会存在线程安全问题。如果多个线程同时访问该实例,可能会导致数据竞争或者线程安全问题。为了避免这种情况,我们可以使用同步机制或者ThreadLocal变量来确保线程安全。
单例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 AOP底层实现原理主要涉及以下三个方面:
- JDK动态代理:JDK动态代理是Spring AOP的默认实现方式。它基于Java反射机制,能够在运行时动态生成代理对象,从而实现对目标对象的方法拦截。Spring AOP使用JDK动态代理实现基于接口的代理,只有实现了接口的类才能被代理。
JDK 动态代理的流程如下:通过 java.lang.reflect.Proxy 类的 newInstance() 方法生成代理对象。在生成代理对象时需传递一个实现了 java.lang.reflect.InvocationHandler 接口的类的实例,即 InvocationHandler 对象。代理对象调用任何方法都会被转发到 InvocationHandler 对象的 invoke() 方法中执行。在 invoke() 方法中,根据方法名和参数类型等信息,使用反射机制调用被代理对象的原始方法。
- CGLIB动态代理:CGLIB动态代理是另一种实现AOP的方式,它是基于字节码实现的动态代理,可以代理那些没有实现接口的类。在运行时,CGLIB通过生成目标类的子类来拦截方法调用,从而实现AOP功能。
CGLIB 的流程如下:通过 CGLIB 提供的 Enhancer 类来生成代理对象。在生成代理对象时需传递一个实现了 MethodInterceptor 接口的类的实例,即 Callback 对象。代理对象调用任何方法都会被转发到 Callback 对象中的 intercept() 方法中执行。在 intercept() 方法中,根据方法名和参数类型等信息,使用反射机制调用被代理对象的原始方法。
- 代理链的创建:Spring AOP采用代理链来实现方法拦截。在代理链中,每个代理对象拦截目标对象的方法调用,并将请求传递给下一个代理对象,直到目标对象的方法被调用。代理链的创建是通过AOP配置文件或注解进行的。
总体来说,Spring AOP底层实现原理就是在运行时动态生成代理对象,通过代理链实现对目标对象的方法拦截。如果是基于接口的代理,则使用JDK动态代理;如果是基于类的代理,则使用CGLIB动态代理。
🍊 Spring循环依赖
首先,什么是循环依赖?
循环依赖指两个或多个类之间出现相互依赖的情况,形成环状依赖。当两个类相互依赖时,如果它们都需要在实例化时对对方进行注入,则会导致循环依赖。
Spring中的循环依赖问题指的是当两个或多个bean相互依赖时,Spring IoC容器无法正确地完成bean的创建和注入,因为它们相互依赖在一起,形成了一个循环。
解决方案:
Spring提供了三种解决方案:
- 构造函数注入
使用构造函数注入是避免循环依赖问题的最佳方式。此方法可以确保依赖项在实例化时已经可用,因此不会存在循环依赖的情况。
- setter方法注入
如果使用了setter方法注入,则可以使用@DependsOn注解来解决循环依赖问题。该注解指定了bean的初始化顺序,以便在需要时确保bean的实例化顺序。
- 代理注入
Spring还提供了代理注入的解决方案。这种方法涉及将一个代理对象注入到类中来解决循环依赖问题。当访问依赖项时,代理对象会延迟解析依赖项,以便在需要时处理循环依赖。
可以使用@Lazy注解来实现延迟加载。它可以在需要时创建bean实例,并在之后重复使用。使用此注解可以确保不会出现循环依赖问题。
🍊 Spring容器启动流程
Spring容器的启动流程主要分为以下几个步骤:
-
加载配置文件:Spring容器会从指定的配置文件中读取配置信息,包括bean的定义、依赖关系、AOP切面等。
-
创建容器:Spring容器启动后会创建一个容器实例,容器负责管理bean的生命周期和依赖关系。
-
扫描包并创建bean定义:Spring容器会扫描指定的包路径,自动创建包中标注了@Component、@Service、@Controller、@Repository等注解的类的bean定义。
-
解析依赖关系:Spring容器会根据bean定义中的依赖关系,自动将依赖的bean注入到需要的bean中。
-
初始化bean:容器会按照指定的顺序依次对bean进行初始化,包括实例化、属性注入、初始化方法执行等。
-
设置代理对象:如果bean需要被AOP切面增强,则容器会为其创建代理对象。
-
完成容器初始化:所有bean初始化完成后,Spring容器启动完成。
在实际开发中,Spring容器的启动可以通过多种方式来实现,包括XML配置和注解配置等。其中XML配置主要通过applicationContext.xml文件来实现,这个配置文件中,告诉Spring容器要创建的bean的名称和类名,这样就可以在Spring容器中实例化这个类,并将其作为一个bean注册到Spring容器中。
除了XML配置之外,注解配置也是一种很常见的配置方式。在Java代码中,我们可以通过在类、字段、方法上添加一些特定的注解来告诉Spring容器如何创建和管理Bean对象。常见的注解包括@Component、@Service、@Controller、@Repository等。
启动Spring容器,首先需要创建一个ApplicationContext对象。这个对象是Spring框架的核心,负责管理所有的Bean对象,以及解决它们之间的依赖关系。ApplicationContext对象可以通过多种方式来创建。一般情况下,我们可以通过ClassPathXmlApplicationContext或AnnotationConfigApplicationContext类来创建一个ApplicationContext对象。其中ClassPathXmlApplicationContext类用于XML配置文件,AnnotationConfigApplicationContext用于注解配置。
一旦配置文件准备好了,就可以开始启动Spring容器了。为了启动容器,需要使用ApplicationContext接口的一个实现类。在这个实现类中,有一个非常重要的方法叫做refresh(),它会触发Spring框架开始加载和初始化所有的Bean对象。
refresh()方法是Spring框架启动过程中的核心方法。首先,refresh()方法会创建一个BeanFactory,这个BeanFactory是一个Bean工厂,是Spring框架中提供的一种对象创建和管理机制。BeanFactory会读取配置文件,通过反射机制实例化对应的Bean,然后将Bean注册到容器中。
接下来,refresh()方法会启动各种后置处理器PostProcessor,后置处理器是一种回调函数,它可以在Bean实例化、初始化之前或之后进行操作,比如修改Bean属性、替换Bean对象等。Spring框架中有很多内置的后置处理器。
比如AutowiredAnnotationBeanPostProcessor用于处理@Autowired和@Inject注解。它会在bean实例化后递归的处理bean的属性,并根据属性上的注解来自动装配依赖。
CommonAnnotationBeanPostProcessor用于处理JSR-250规范的注解,包括@Resource、@PostConstruct和@PreDestroy。
InitDestroyAnnotationBeanPostProcessor用于处理@PostConstruct和@PreDestroy注解,它会在bean的初始化和销毁阶段对相应的方法进行回调。
在执行了后置处理器之后,refresh()方法会执行BeanFactory的预实例化单例Bean,这个过程会通过调用getBean()方法来获取Bean实例。在这个过程中,如果Bean实现了InitializingBean接口,那么Spring容器会调用它的afterPropertiesSet()方法来完成Bean的初始化。如果Bean配置了init-method,那么Spring容器也会调用它指定的初始化方法。
最后,refresh()方法会发布上下文事件,这些事件会被注册到各种事件监听器中,用于监控和管理容器生命周期中的各个阶段。Spring框架中有很多内置的事件,这些事件可以在Spring应用程序上下文中定义的Bean中使用,以便在特定生命周期事件发生时执行特定的代码。例如,可以使用这些事件来处理数据源连接、缓存清除、应用程序状态检查等应用程序行为。
比如ContextRefreshedEvent:当ApplicationContext被初始化或刷新时,该事件被发布。该事件适用于需要在启动时执行某些操作的应用程序。
ContextStartedEvent:该事件表示ApplicationContext已启动,用于在应用程序启动后执行某些操作,例如在spring boot应用程序中启动一个后台线程。
ContextStoppedEvent:当ApplicationContext停止时,该事件被发布。该事件适用于在应用程序停止时执行某些清理操作的应用程序。
ContextClosedEvent:当ApplicationContext关闭时,该事件被发布。该事件适用于在应用程序关闭时执行某些清理操作的应用程序。
🍊 Spring事务及传播机制底层原理
Spring事务的实现原理就是通过拦截@Transactional注解标识的方法,使用事务增强器对这些方法进行事务管理。其中关键的是事务管理器和事务属性源的配置和使用。
Spring事务管理是基于AOP的,使用了代理模式和注解的方式来统一管理和控制业务方法的事务,使用了JDBC或Hibernate等ORM框架提供的事务支持。Spring 中事务处理的本质是对数据访问层(Data Access Layer,DAL)实现事务管理。
在开启事务的方法前,Spring通过动态代理为该方法生成一个代理对象,代理对象在调用方法之前会开启一个事务,在方法执行结束后,代理对象会根据方法的执行结果提交或回滚事务。在代理对象的实现中,Spring使用了TransactionSynchronizationManager类来维护和控制事务的状态和传播行为。
TransactionSynchronizationManager维护了一个ThreadLocal变量来保存当前事务的状态,包括当前事务是否已开启、当前事务的传播行为等信息。在多个事务方法相互调用时,Spring会根据事务方法的传播行为来判断是否开启新的事务,以及如何协调各个事务方法之间的事务管理。
Spring事务的实现原理可以简单理解为以下几个步骤:
-
从配置文件中获取PlatformTransactionManager,这个事务管理器是管理事务的关键。
-
创建TransactionAttributeSource,用来获取方法上定义的事务属性,如事务传播特性、事务隔离级别等。
-
创建TransactionAdvisor,这个切面用来指定事务增强器的增强器和切入点,从而实现对@Transactional注解的拦截和增强。
-
启用事务注解,通过tx:annotation-driven/标签启用事务注解,并指定事务管理器和事务属性源。这样就可以在代码中使用@Transactional注解来管理事务了。
1.从配置文件中获取PlatformTransactionManager
在配置文件中,我们可以使用tx:annotation-driven/标签来启用事务注解,这样就可以在代码中使用@Transactional注解来管理事务了。这个标签会自动为我们创建一个TransactionInterceptor,这个拦截器负责拦截@Transactional注解标识的方法,并对这些方法进行事务管理。
在TransactionInterceptor中,我们需要注入PlatformTransactionManager,这个事务管理器是事务实现的关键。在TransactionInterceptor中,会有一个initPlatformTransactionManager()方法,这个方法用来从配置文件中获取PlatformTransactionManager。通常我们会将DataSourceTransactionManager配置为默认的事务管理器,这个管理器可以管理单个数据源的事务。
2.创建TransactionAttributeSource
在Spring中,事务增强器负责事务的具体管理和控制,其中核心的实现是TransactionAttributeSource接口和TransactionInterceptor类。TransactionAttributeSource接口用来获取方法上定义的事务属性,而TransactionInterceptor则负责事务属性的解析和管理。
在TransactionInterceptor中,我们需要注入TransactionAttributeSource,这个事务属性源用来获取方法上定义的事务属性,如事务传播特性、事务隔离级别等。
在TransactionAttributeSource中,我们需要实现getTransactionAttribute方法,这个方法负责获取指定方法上的事务属性。通常我们会使用AnnotationTransactionAttributeSource来实现这个接口。
3.创建TransactionAdvisor
在Spring中,我们需要创建一个TransactionAdvisor,这个切面用来指定事务增强器的增强器和切入点。TransactionAdvisor会在Spring的AOP框架中注册,从而实现对@Transactional注解的拦截和增强。
在TransactionAdvisor中,我们需要注入TransactionInterceptor和Pointcut,这个切点用来指定需要拦截的方法。
4.启用事务注解
在Spring中,我们可以通过tx:annotation-driven/标签来启用事务注解。这个标签会为我们创建一个TransactionInterceptor,并自动配置TransactionAdvisor,从而实现了事务注解的管理和控制。同时,也需要注意在配置文件中指定事务管理器和事务属性源。
🍊 Spring传播机制底层原理
Spring支持以下7种事务传播行为:
- PROPAGATION_REQUIRED:如果当前存在事务,则加入该事务;否则新建事务,并在方法执行结束后提交事务。
- PROPAGATION_SUPPORTS:如果当前存在事务,则加入该事务;否则不开启事务。
- PROPAGATION_MANDATORY:如果当前存在事务,则加入该事务;否则抛出异常。
- PROPAGATION_REQUIRES_NEW:不管当前是否存在事务,都新建一个事务,并在方法执行结束后提交事务。
- PROPAGATION_NOT_SUPPORTED:不管当前是否存在事务,都不开启事务。
- PROPAGATION_NEVER:如果当前存在事务,则抛出异常;否则不开启事务。
- PROPAGATION_NESTED:如果当前存在事务,则在已有事务中嵌套一个事务;否则新建事务,并在方法执行结束后提交事务。
Spring的事务传播机制的实现原理:
在Spring框架内部,事务的传播机制是通过ThreadLocal对象来实现的。ThreadLocal是一个线程本地变量,它可以在当前线程中存储某个值,并且这个值可以被当前线程的任何方法所共享和修改。在Spring中,我们可以通过TransactionSynchronizationManager类来管理ThreadLocal变量。
在Spring中,开启事务的方法通常被称为事务模板方法。事务模板方法负责创建事务,并且将当前线程的状态保存在ThreadLocal变量中。在执行业务方法前,Spring事务管理器会检查当前线程的状态,如果当前线程已经存在事务,则直接使用该事务;否则创建一个新事务。
在Spring中,每个事务方法都是由一个或多个拦截器组成的。事务拦截器负责拦截业务方法执行前后的各种事件,并且在恰当的时候执行提交或回滚事务等操作。在Spring中,我们可以通过TransactionInterceptor类来实现事务拦截器。
在Spring的事务传播机制中,每个事务方法都是独立的,它们的事务行为是相互独立的。在事务方法内部调用其他事务方法时,Spring会根据所设置的传播行为来决定是否开启新事务,或者将当前事务合并到已有事务中。
Spring的事务传播机制是基于AOP实现的,它首先在调用业务方法之前,开启事务,并将事务状态保存在ThreadLocal变量中;然后执行业务方法;最后在业务方法执行结束后,根据事务状态来决定是提交还是回滚事务。
Spring的事务管理API主要包括以下三个接口:
PlatformTransactionManager是事务管理器的顶层接口,它定义了使用事务的基本方法,如开启、提交、回滚、暂停、恢复等。所有的事务管理器都需要实现PlatformTransactionManager接口。
TransactionDefinition是事务定义接口,它定义了一个事务的属性,如事务的隔离级别、传播行为、超时时间和只读属性等。所有的事务管理器必须支持TransactionDefinition接口的所有属性。
TransactionStatus是事务状态接口,它定义了事务的当前状态,例如是否已经开始、是否已经提交、是否已经回滚等。所有的事务管理器必须支持TransactionStatus接口的所有状态。
Spring的事务传播机制的实现主要涉及以下几个核心类:
TransactionSynchronizationManager是Spring事务同步管理器,它负责处理同步回调和资源清理,以及管理线程本地变量资源。 在Spring的事务传播机制中,TransactionSynchronizationManager使用ThreadLocal来保存当前线程的事务状态和事务资源。
TransactionAspectSupport是Spring事务切面支持类,它是Spring事务传播机制的核心实现类。TransactionAspectSupport类继承自AspectJAfterAdvice类,实现了org.aopalliance.intercept.MethodInterceptor接口,它可以作为一个通用的事务拦截器来拦截任何一个Spring Bean中的方法调用,并根据所设置的传播行为来决定是否开启新事务,或者将当前事务合并到已有事务中。
AbstractPlatformTransactionManager是PlatformTransactionManager接口的抽象实现类,它提供了大部分的PlatformTransactionManager接口方法的默认实现,具体实现细节由其子类来完成。
AbstractTransactionStatus是TransactionStatus接口的抽象实现类,它提供了大部分的TransactionStatus接口方法的默认实现,具体实现细节由其子类来完成。
🍊 Spring IOC容器加载过程
Spring IoC容器的加载过程主要包括加载配置文件、解析和注册BeanDefinition、初始化BeanDefinition、加载Bean、填充Bean属性、初始化Bean和销毁Bean等步骤。
Spring IOC容器加载过程:
-
配置文件读取:Spring IOC容器会读取XML配置文件,通过解析XML文件获取Bean定义信息。
-
Bean实例化:通过反射机制,根据Bean定义信息创建Bean实例。
-
Bean属性注入:通过反射机制,将定义在XML文件中的属性值设置给Bean对象。
-
Bean对象注册:将实例化后的Bean对象注册到Spring IOC容器中,以供后续使用。
-
Bean生命周期管理:Spring IOC容器会管理Bean对象的整个生命周期,包括Bean的创建、初始化和销毁。
🍊 Spring依赖注入
依赖注入是Spring IOC容器的核心功能之一。它的作用是将Bean之间的依赖关系交给Spring来管理,而不是由程序员手动管理。Spring IOC容器在创建Bean实例时,会自动将依赖的对象注入到Bean中。
依赖注入的方式有三种:
-
构造函数注入:通过构造函数参数传递,将依赖的对象注入到Bean中。
-
Setter方法注入:通过Setter方法设置,将依赖的对象注入到Bean中。
-
接口注入:通过实现接口,在接口中定义Setter方法,将依赖的对象注入到Bean中。
依赖注入的好处是可以降低代码的耦合度,提高代码的可维护性和可扩展性。
🍊 Spring的自动装配
Spring的自动装配是一种自动化的任务分配方式,它能够自动地将应用程序中的各个模块组合在一起,形成完整的应用程序。从面试者的角度来讲,可以从以下几个方面来详细说明Spring的自动装配:
- 什么是Spring的自动装配?
Spring的自动装配是一种基于控制反转(IoC)和依赖注入(DI)的实现方式,它能够自动地将应用程序中的Bean装配到各个模块中。当注入的Bean类型匹配时,Spring会自动完成Bean的注入。
- Spring自动装配的优势是什么?
Spring的自动装配能够极大地简化开发操作,减少了手动配置Bean的步骤,提高了开发效率。同时,它也能够消除重复代码,增强了模块之间的解耦性,使得应用程序更加易于维护和扩展。
- Spring自动装配的方式有哪些?
Spring自动装配有三种方式:默认的基于名称的自动装配、基于类型的自动装配和基于注解的自动装配。
- 基于名称的自动装配:这种方式是默认的自动装配方式,Spring会自动将属性名与容器中定义的Bean名称进行匹配,如果匹配成功,则将Bean注入到属性中。
- 基于类型的自动装配:这种方式是根据类型来自动进行装配的,Spring会自动将属性的类型与容器中定义的Bean类型进行匹配,如果匹配成功,则将Bean注入到属性中。
- 基于注解的自动装配:这种方式是根据注解来进行自动装配的,通过在属性上使用@Autowired和@Qualifier注解,Spring会自动将符合条件的Bean注入到属性中。
- Spring的自动装配可能会出现的问题有哪些?
Spring的自动装配虽然能够提高开发效率,但在实际开发中也可能会遇到一些问题,比如:
- 自动装配的Bean可能会出现多个候选者的情况;
- 自动装配可能会造成Bean之间的循环依赖问题;
- 自动装配可能会导致开发者对Bean的装配过程不够清晰,降低了代码的可读性。
-
Spring使用以下规则来决定需要自动装配的Bean
默认情况下,Spring会尝试按照名称进行自动装配。这意味着Spring会查找与依赖属性名称相同的Bean名称,并将Bean自动注入到属性中。
如果按照名称进行自动装配失败,Spring会尝试按照类型进行自动装配。这意味着Spring会查找与依赖属性类型相同的Bean,并将Bean自动注入到属性中。
如果按照类型进行自动装配失败,Spring会尝试使用构造函数进行自动装配。这意味着Spring会查找与构造函数参数类型相同的Bean,并将Bean作为参数自动注入到构造函数中。
如果以上三种规则均无法完成自动装配,则Spring会抛出异常。
需要注意的是,Spring在进行自动装配时会优先使用已经被标记为Primary的Bean,如果没有找到Primary Bean,才会使用其他的进行自动装配。
总的来说,Spring的自动装配是一种方便而快捷的开发方式,可以大大提高开发效率,但在实际使用中还是需要谨慎使用,避免出现不必要的问题。
🍊 Spring6.0核心新特性
作为面试者,要讲清楚Spring6.0的核心新特性可以从以下几个方面展开:
-
Spring6.0于2021年9月发布,Spring 6.0的核心新特性包括:支持Java 17和Java 18、将应用程序编译成原生镜像以支持云原生环境、引入AOT编译基础、改进Spring Boot自动配置、更新Spring Data以支持最新持久性框架、更新Spring WebSocket以支持最新WebSocket协议、改进Spring Security以支持OAuth 2.0和最新安全标准,以及改进测试模块以支持JUnit 5和其他测试框架。此外,还提供了对最新Web容器和持久性框架的访问。
-
强调Spring6.0在模块化和打包方面的改进。采用更细粒度的模块化设计,每个模块更加独立和可替换。同时引入新的包布局,使依赖关系更清晰,便于开发和维护。
-
提及Spring6.0在性能方面的优化措施。减少内存占用、提高启动速度和运行效率,使应用程序能更好地处理高并发和大数据量的场景。
-
强调Spring6.0与Project Reactor的集成,为响应式编程提供更好的支持。使得开发响应式应用更加简单和高效。
-
指出Spring6.0在安全性方面的改进。支持最新的安全标准如TLS 1.3和HTTP/2,提供更强的密码学支持,增强应用程序的安全性。
-
强调Spring6.0在测试方面的简化。引入新的测试模块,使测试编写更加容易和直观。同时与主流测试框架如JUnit和Mockito等更好集成。
-
强调Spring6.0将提供长期支持,确保应用程序的稳定性和可靠性。让开发人员放心使用Spring6.0进行开发。
🍊 Spring Boot自动装配
Spring Boot启动的时候会通过@EnableAutoConfiguration注解找到META-INF/spring.factories配置文件中的所有自动配置类,并对其进行加载,而这些自动配置类都是以AutoConfiguration结尾来命名的,它实际上就是一个JavaConfig形式的Spring容器配置类,它能通过以Properties结尾命名的类中取得在全局配置文件中配置的属性如:server.port,而XxxxProperties类是通过@ConfigurationProperties注解与全局配置文件中对应的属性进行绑定的。
启动类的@SpringBootApplication注解由@SpringBootConfiguration,@EnableAutoConfiguration,@ComponentScan三个注解组成,三个注解共同完成自动装配;
@SpringBootConfiguration 注解标记启动类为配置类
@ComponentScan 注解实现启动时扫描启动类所在的包以及子包下所有标记为bean的类由IOC容器注册为bean
@EnableAutoConfiguration通过 @Import 注解导入 AutoConfigurationImportSelector类,然后通过AutoConfigurationImportSelector 类的 selectImports 方法去读取需要被自动装配的组件依赖下的spring.factories文件配置的组件的类全名,并按照一定的规则过滤掉不符合要求的组件的类全名,将剩余读取到的各个组件的类全名集合返回给IOC容器并将这些组件注册为bean
Spring Boot自动装配的底层实现原理主要依赖于Spring Framework的核心组件:IoC容器。Spring Boot在启动时会自动扫描项目中的所有类,并通过IoC容器将这些类进行实例化和注入。
具体来说,Spring Boot通过以下几个步骤实现自动装配:
-
扫描类路径下的所有类:在启动时,Spring Boot会自动扫描类路径下的所有类(包括jar包中的类),并将这些类进行解析和加载。
-
根据条件自动装配:Spring Boot通过条件注解(如@ConditionalOnClass、@ConditionalOnBean等)来判断哪些类需要被自动装配。在满足条件的情况下,Spring Boot会自动创建该类的实例,并将其注入到IoC容器中。
-
自动配置:除了通过条件注解判断哪些类需要被自动装配外,Spring Boot还提供了一系列自动配置类,用于自动配置各种常用的组件(如数据库连接池、Web容器等)。当自动装配某个组件时,Spring Boot会先检查该组件是否已经存在,如果不存在,则自动创建该组件的实例并注入到IoC容器中。
-
处理自定义配置:Spring Boot还支持外部化配置,可以通过properties文件或YAML文件来配置应用程序。在启动时,Spring Boot会读取这些配置文件,并将其注入到IoC容器中,以便在应用程序中进行使用。
总的来说,Spring Boot的自动装配机制主要依赖于IoC容器和条件注解,通过自动扫描类路径、判断条件和自动配置组件来实现自动装配。这种机制可以大大简化应用程序的配置工作,提高开发效率。
🍊 Spring Framework的SPI机制
介绍SPI机制:SPI机制是Java平台提供的一种服务发现机制,被广泛用于实现框架扩展。SPI机制将接口实现类的全限定名配置在文件中,并由服务加载器读取配置文件,加载实现类。Spring Framework将SPI机制用于实现可扩展性,使得第三方插件可以与Spring核心框架无缝集成。
SPI机制的实现方式:Spring Framework通过org.springframework.core.io.support.SpringFactoriesLoader类来实现SPI机制。该类会在META-INF/spring.factories文件中查找实现类的全限定名,并实例化对应的对象。第三方插件只需要在META-INF/spring.factories文件中声明自己的实现类,就可以被Spring Framework自动加载和集成。
SPI机制的优点:SPI机制为很多框架扩展提供了可能,使得Spring Framework具有很高的可扩展性。开发者可以通过实现Spring Framework提供的扩展点接口,将插件集成到Spring应用程序中。此外,SPI机制还使得代码更加模块化,降低了代码的耦合度,提高了代码的可维护性和可重用性。
SPI机制在Spring中的应用:SPI机制在Spring中广泛应用于各个模块,如JDBC、Web、AOP等。例如,在JDBC模块中,Spring通过SPI机制加载不同类型的数据库驱动;在Web模块中,Spring通过SPI机制加载不同类型的Web容器(如Tomcat、Jetty等)。
注意事项和限制:虽然SPI机制提供了很好的扩展性,但也存在一些注意事项和限制。首先,需要确保SPI机制所需的文件(如META-INF/spring.factories)正确配置;其次,需要避免实现类之间的冲突;最后,需要考虑性能开销,因为SPI机制需要加载和初始化实现类。
🍊 Spring Boot启动过程
Spring Boot的启动过程是一个逐步初始化的过程,主要包括以下几个步骤:
-
创建SpringApplication对象:在启动过程中,首先会创建一个SpringApplication对象。该对象负责管理整个Spring Boot应用的生命周期。
-
加载配置文件:Spring Boot会自动加载应用程序的配置文件,例如application.properties或application.yml。这些配置文件包含了各种配置项,如服务器端口、数据库连接等。
-
创建并配置EmbeddedServletContainerFactory:EmbeddedServletContainerFactory是Spring Boot中用于创建内嵌的Servlet容器的工厂类。它会读取配置文件中的相关信息,如嵌入式服务器类型、上下文路径等,并根据这些信息创建一个内嵌的Servlet容器。
-
注册监听器和事件处理器:在创建EmbeddedServletContainerFactory之后,Spring Boot会注册一些默认的监听器和事件处理器。这些监听器和事件处理器可以处理应用程序的生命周期事件,如应用程序启动、停止等。
-
准备环境:在完成以上步骤之后,Spring Boot会进行一些准备工作,包括加载应用程序的基础依赖、配置环境变量等。这样可以使应用程序在一个合适的环境中运行。
-
启动嵌入式Servlet容器:准备工作完成后,Spring Boot会调用EmbeddedServletContainerFactory的start()方法来启动内嵌的Servlet容器。这个容器负责处理HTTP请求和响应。
最后,通过以上步骤,Spring Boot应用程序会成功启动并运行起来。在整个启动过程中,Spring Boot会自动管理各种组件和依赖关系,使得开发者可以更加专注于业务逻辑的开发,而不需要过多关注底层的细节。
🍊 SpringMVC执行流程
🍊 Dubbo服务发现与调用
Dubbo的服务发现机制包含提供者、消费者和注册中心三个参与角色。Dubbo提供者实例注册URL地址到注册中心,注册中心负责对数据进行聚合,Dubbo消费者从注册中心读取地址列表并订阅变更,每当地址列表发生变化,注册中心将最新的列表通知到所有订阅的消费者实例。
简单来说,Dubbo服务发现与调用的流程如下:
- 服务提供者启动时,向注册中心注册自己提供的服务。
- 服务消费者启动时,向注册中心订阅自己所需要的服务。
- 服务消费者从注册中心获取服务提供者的信息,然后通过网络调用服务提供者的方法。
- 服务消费者调用完成后,将结果返回给服务消费者。
- 服务消费者可以将结果存储到缓存中,提高性能。
🍊 Dubbo容错机制
容错机制介绍:在分布式系统中,由于网络不稳定、服务提供者宕机等原因,可能会出现服务调用失败的情况。为了提高系统的可靠性,Dubbo提供了多种容错机制。
Failover(失败重试):当服务调用失败时,Failover机制会自动重试其他服务提供者。重试次数可配置,默认情况下会重试一次。适用于读操作或幂等性操作。
Failfast(快速失败):Failfast机制只调用一次服务提供者,如果调用失败,则立即报错。适用于写入审计日志等操作。
Failsafe(失败安全):Failsafe机制对调用结果不敏感,出现异常时,会自动记录到失败日志,并告知调用方。适用于写入审计日志等操作。
Failback(失败自动恢复):Failback机制后台记录每一次调用和服务提供者的响应信息,当调用失败时,会自动从后台记录中寻找响应信息以做出自动恢复。适用于异地多机房场景。
Forking(并行调用多个服务提供者):如果某个服务调用失败,Forking机制会并行调用其他多个服务提供者,只要有一个成功即返回。适用于读操作或幂等性操作。
Broadcast(广播通知):Broadcast机制会逐个通知所有提供者,逐个调用,任意一台报错则报错。适用于通知操作。
🍊 Dubbo负载均衡
负载均衡介绍:在分布式系统中,当多个服务提供者同时存在时,如何选择合适的服务提供者进行调用以保证系统的性能和稳定性。Dubbo提供了多种负载均衡策略来满足不同的场景需求。
随机(Random):随机选择一个服务提供者进行调用。该策略简单易用,适用于读写操作,但随机选择可能导致请求分布不均。
轮询(RoundRobin):按照轮询顺序选择服务提供者进行调用。该策略保证了请求的均匀分布,适用于读操作,但可能导致某些服务提供者负载过重。
最小活跃调用数(LeastActive):选择活跃调用数最少的服务提供者进行调用。该策略可以优化系统性能,减少服务提供者的负载压力,但可能存在数据倾斜的问题。
一致性哈希(ConsistentHash):根据请求的哈希值选择一个服务提供者进行调用。该策略可以保证相同请求总是被相同的节点处理,适用于读操作,但可能存在数据倾斜的问题。
自定义策略(Customized):开发人员可以根据实际需求自定义负载均衡策略。该策略提供了灵活性和扩展性,可以满足各种复杂的场景需求。
🍊 Dubbo序列化协议
序列化协议介绍:在分布式系统中,当对象需要在网络上进行传输时,需要将对象序列化为字节流以便于传输,到达接收方后再将字节流反序列化回对象。Dubbo支持多种序列化协议,以满足不同的场景需求。
Hessian序列化协议:Hessian是一种基于二进制的序列化协议,其特点是简单易用、高效。Dubbo默认使用Hessian作为序列化协议。Hessian序列化后的数据体积较小,适合用于网络传输。
Kryo序列化协议:Kryo是一种基于Java的序列化协议,其特点是速度快、压缩比高。Kryo序列化后的数据体积较小,适合用于网络传输。Dubbo也支持使用Kryo作为序列化协议。
FST序列化协议:FST是一种基于Java的序列化协议,其特点是性能较好、序列化后的数据体积较大。FST序列化协议适用于对性能要求较高且不介意数据体积较大的场景。
Protobuf序列化协议:Protobuf是由Google开发的序列化协议,其特点是高效、灵活、可读性较好。Protobuf序列化后的数据体积较小,适合用于网络传输。Dubbo也支持使用Protobuf作为序列化协议。
🍊 Dubbo动态感知服务下线
在Zookeeper上,Dubbo采用树形结构来维护Dubbo服务提供端的协议地址。当Dubbo服务提供方出现故障导致Zookeeper剔除了这个服务的地址,那么Dubbo服务消费端需要感知到地址的变化,从而避免后续的请求发送到故障节点,导致请求失败。
这个能力是通过Zookeeper里面提供的Watch机制来实现的。简单来说,Dubbo服务消费端会使用Zookeeper里面的Watch来针对Zookeeper Server端的/providers节点注册监听。一旦这个节点下的子节点发生变化,Zookeeper Server就会发送一个事件通知Dubbo Client端。Dubbo Client端收到事件以后,就会把本地缓存的这个服务地址删除,这样后续就不会把请求发送到失败的节点上,完成服务下线感知。
🍊 ZooKeeper选举
ZooKeeper的选举机制分为两种情况:第一次启动选举和leader宕机选举。
第一次启动选举中,如果有N个服务器,那么就需要选出N/2台服务器作为候选人,也就是半数原则。如果服务器数量为奇数,比如3台服务器,那么就需要选出2台服务器作为候选人。在ZooKeeper的机制中,只有超过半数的节点才能正常工作,也就是说至少要有2N+1台服务器才能保证ZooKeeper集群的正常运行。
在leader宕机选举中,ZooKeeper会通过选举机制选出一个新的leader。如果一个服务器想要成为leader,它需要获得N/2+1票,也就是超过半数的投票。在ZooKeeper的机制中,只有获得超过半数的投票的服务器才能成为新的leader。
选举机制原理:ZooKeeper的选举机制基于一种投票制度。在ZooKeeper的集群中,每个服务器都可以充当投票人或候选人。当服务器启动时,它会成为候选人并请求其他服务器的投票。要成为候选人,需要获得N/2+1票,其中N是集群中的服务器数量。
选举过程:选举过程分为两个阶段:提名阶段和确认阶段。在提名阶段,候选人向其他服务器发起投票请求。如果其他服务器同意候选人的提名,则会向该候选人返回投票。在确认阶段,候选人检查自己是否获得了N/2+1票,如果满足该条件,则成为领导者。
选举机制特点:ZooKeeper的选举机制具有以下特点:首先,它采用了半数原则,即选出N/2台服务器作为候选人;其次,只有获得超过半数投票的服务器才能成为领导者;最后,ZooKeeper的选举机制具有自组织和容错性,能够保证在故障情况下快速选举新的领导者。
🍊 ZooKeeper脑裂与假死
ZooKeeper脑裂问题是指在集群中的部分节点之间出现无法通信的情况,导致多个节点同时成为集群的“主节点”,从而引发数据不一致和服务不可用的情况。如果集群中的不同部分在同一时间都认为自己是活动的,就会出现脑裂症状。比如当一个cluster里有两个节点,它们需要选出一个master,当它们之间的通信完全没有问题时,就会选出其中一个作为master。但是,如果它们之间的通信出现问题,那么两个节点都会认为没有master,因此每个节点都会自己选举成master,导致集群里出现两个master的情况。
解决ZooKeeper脑裂问题的方法包括以下几种:
(1)使用ZooKeeper 3.5.x版本的新特性,如“动态重配置”(Dynamic Reconfiguration)、自动选举Leader等,有效减少脑裂问题的发生。
(2)在部署ZooKeeper时,将节点数设置为奇数个,如3、5、7等,避免出现分裂的情况。
(3)使用虚拟IP(Virtual IP)等技术,将多个ZooKeeper节点绑定到同一个IP地址上以实现高可用性和负载均衡,尽可能避免节点之间的通信故障。
(4)定期监测ZooKeeper集群状态,识别并处理失效节点,进行维护和修复。
(5)根据不同的场景和应用,采用不同的解决方案,如使用分布式锁、消息队列等技术来避免脑裂问题的发生。
(6)添加心跳线,即将两个namenode之间加入两条心跳线路,一条断开时仍然能够接收心跳报告,保证集群服务正常运行。
(7)启用磁盘锁,保证集群中只有一台namenode获取磁盘锁,对外提供服务,避免数据错乱的情况。在HA上使用“智能锁”,即active的namenode检测到了心跳线全部断开时才启动磁盘锁,正常情况下不上锁,保证了假死状态下仍然只有一台namenode的节点提供服务。
(8)设置仲裁机制,如提供一个参考的IP地址,当出现脑裂现象时,双方接收不到对方的心跳机制,但是能同时ping参考IP,如果有一方ping不通,那么表示该节点网络已经出现问题,则该节点需要自行退出争抢资源的行列,或者更好的方法是直接强制重启,这样能更好地释放曾经占有的共享资源,将服务的提供功能让给功能更全面的namenode节点。
心跳超时可能是由于Master故障或与ZooKeeper间的网络问题而导致的。在这种情况下,ZooKeeper可能会误判Master已经失效并通知其他节点切换,称之为“假死”。实际上,Master并未真正死亡,而是因为与ZooKeeper之间的网络问题而导致的误判。在这种情况下,一个Slave节点将成为新的Master,而原始Master并未真正死亡。然而,如果部分客户端连接到新Master,而其他客户端仍连接到原Master,可能会导致数据不一致的问题,特别是在这两个客户端试图更新同一数据时。
ZooKeeper假死问题通常是由于节点或集群过载、网络问题或硬件故障等原因导致的。它指的是ZooKeeper的某些节点或整个集群停止响应,但节点或集群的进程仍在运行。
ZooKeeper脑裂问题是指ZooKeeper集群中不同节点之间出现网络分区甚至物理分离,导致节点无法通信,从而导致数据不一致或服务中断的问题。通常是由于网络故障、硬件故障或错误的配置等原因导致的。
总之,ZooKeeper假死问题和ZooKeeper脑裂问题都可能导致ZooKeeper集群出现问题,但它们的原因和表现方式略有不同。在实际应用中,需要采取不同的措施来避免这两类问题的出现,并及时处理和恢复问题以确保集群的正常运行。
ZooKeeper假死问题是一种常见的故障,通常是由以下原因导致的。首先,网络问题可能导致ZooKeeper集群节点无法正常同步状态。其次,磁盘空间不足可能会导致ZooKeeper无法正常工作,因为ZooKeeper的快照和日志文件可能会占用大量磁盘空间。此外,过度负载也可能导致ZooKeeper无法及时响应客户端请求。最后,内存不足可能会导致ZooKeeper无法正常工作。
为了解决ZooKeeper假死问题,需要采取一些措施。首先,应检查集群节点之间的网络连接,确保它们正常,没有网络问题。其次,需要定期清理ZooKeeper的快照和日志文件来释放磁盘空间。另外,应该实施负载均衡策略,将客户端请求分散至不同的ZooKeeper节点,以降低负载。最后,增加ZooKeeper集群节点的内存容量可以提高其运行效率,应该考虑这一点来解决内存不足的问题。通过这些措施,可以有效地解决ZooKeeper假死问题,确保其运行正常。
🍊 ZooKeeper的Zab协议
Zab协议用于确保数据的一致性和可靠性。在ZooKeeper的集群中,每个服务器都有可能成为领导者,但为了维护整个集群的状态一致性,需要通过领导者选举(Leader Election)机制来确定领导者。当新的领导者产生后,它需要确保自己的状态与其他服务器同步,这就需要利用Atomic Broadcast机制。领导者负责处理客户端的请求,并将请求广播给其他服务器。只有当大多数服务器确认收到请求后,领导者才会将请求提交到自己的本地存储,并向客户端反馈结果。这种方式可以确保所有服务器的状态保持一致,从而提高整个集群的可用性和可靠性。
Zab协议包含恢复模式和广播模式两种运行状态。在恢复模式下,ZooKeeper通过一种选举算法从集群中选取一个领导者节点,并利用Zab协议将领导者节点的数据同步到其他所有节点。选举算法在分布式系统中是一种常见的算法,被用于选择一个节点作为系统的主节点。ZooKeeper的选举算法采用Paxos算法实现,目的是选举一个领导者节点,负责协调各个节点的状态以确保数据的一致性。
在广播模式下,当领导者节点接收到写请求时,会将请求广播到其他节点进行处理,以确保数据的一致性和可靠性。广播模式是分布式系统中的一种常见通信方式,通过在网络中广播消息,可以保证消息被所有节点接收,从而从根本上确保数据的一致性。在ZooKeeper中,领导者节点会将写请求广播给所有节点,其他节点会相应地处理这些请求,以确保数据的一致性和可靠性。
🍊 ZooKeeper的选举时间过长
在ZooKeeper 的协调系统中,只有一个核心节点,即Leader,能够进行写操作,而其他节点,称为追随者,只能处理读取操作。当Leader节点出现故障或者网络问题时,系统需要重新选择新的Leader节点。选择过程可能会需要一定的时间,如果这个过程过长,可能会对服务的可用性产生负面影响。为了应对这个问题,提出了选举前提供服务的策略。这种策略意味着,在Leader节点出现故障或者网络问题时,所有的追随者节点都有机会参与到Leader节点的选举中来,每一个节点都可以在一段时间内负责Leader节点的服务,直到新的Leader节点被选举出来。这种策略可以提高系统的可用性和稳定性,确保服务不会因为选举过程耗时过长而中断。
🍊 ZooKeeper的Quorum机制
在ZooKeeper集群中,Quorum机制是必要手段之一以实现高可用性。由于分布式系统中存在网络故障、硬件故障和软件故障等原因会导致节点宕机,所以需要设计一种机制来保证整个系统可用。Quorum机制是一种常见的实现高可用性的手段,核心思想是只有当超过一半的节点处于活动状态时,集群才能正常工作。因为节点在进行数据读写操作时需要相互协调和同步,节点不足半数则无法进行协调和同步,会导致系统故障。在5个节点构成的ZooKeeper集群中,只要3个及以上的节点处于运行状态,集群就能正常工作。若少于3个节点处于运行状态,则整个集群将无法提供服务。
在ZooKeeper集群中,每个节点都有编号,编号越大,节点在集群中地位越高,投票权也越大。节点在参与投票时,需要与其他节点建立连接并发送投票请求,每个节点都有一个投票箱用于记录已收到的投票结果。其他节点对该请求进行投票并返回给发起请求的节点。发起请求的节点会记录投票箱中的投票结果,一旦超过半数的节点投票通过,该请求就会被认为是通过的。
Quorum机制的优点是可以快速恢复系统的正常运行,特别是节点宕机时。当一个节点宕机时,集群中的其他节点会检测到该节点的失效,并重新进行投票。如果投票结果表明当前节点不再是集群的一部分,那么集群就会删除该节点并重新选举新的主节点,保证了系统的高可用性。
🍊 ZooKeeper的ACL访问控制列表
ACL介绍:ACL是ZooKeeper访问控制列表的缩写,是一种权限管理系统。它描述了哪个用户对哪些ZNode(ZooKeeper节点)拥有何种权限。
ACL特点:ACL有以下特点:
与ZNode关联:ACL与每个ZNode相关联,而不是与客户端会话关联。这意味着即使客户端会话发生变化,只要客户端拥有相应的ACL,就可以执行相应的操作。
针对客户端:ACL是针对ZooKeeper客户端的,而不是针对ZooKeeper集群中的服务器的。这意味着只有特定的客户端才能访问特定的ZNode并执行特定操作。
优先级排序:ACL按照优先级排序,当客户端具有多个访问权限时,按照优先级进行排序。
支持多种权限类型:ACL支持读取、写入、创建和删除等权限类型。
ACL格式:ACL使用特定格式进行描述,例如:
digest:username=DIGEST:表示基于digest的ACL,其中username是用户名,DIGEST是用户的认证码。DIGEST是由用户密码生成的。
ip:ipAddress=ipAddress:表示基于IP地址的ACL,其中ipAddress是IP地址。
auth:username:表示全局认证的ACL,其中username是用户名。
示例:
arduino
digest:user1=test123:ip:192.168.1.1/10
digest:user2=test456:ip:192.1.1.2/24
auth:user3
这个ACL列表表示user1和user2分别拥有不同的权限。user1可以访问IP地址在192.168.1.1/10范围内的所有节点,user2可以访问IP地址在192.1.1.2/24范围内的所有节点,而user3具有全局访问权限。
🍊 @Configuration、@Autowired、@Resource、@ComponentScan、@Conditional、@Lazy、@Primary、@Import、@SpringBootApplication注解的底层实现
@Configuration:这个注解用于标记一个类作为Spring的配置类。底层实现是通过Java的反射机制,将类中的属性和方法信息提取出来,并通过Spring的配置解析器解析为Bean定义。当一个类被标记为@Configuration时,底层实现过程如下:首先,Java的反射机制会加载这个类,并获取该类中的属性和方法信息。接着,Spring的配置解析器会解析该类中的注解和属性信息,将其转换为Bean定义。Bean定义包含了Bean的类型、名称、属性等信息,这些信息将被用于创建Bean实例。最后,Spring容器会将Bean定义注册为Spring容器中的Bean,以便在应用程序中使用。
@Autowired:这个注解用于自动装配Bean依赖。底层实现是通过Spring的依赖注入机制,根据注解所指定的类型或名称,自动将相应的Bean注入到目标对象中。当一个类被标记为@Autowired时,底层实现过程如下:Spring的依赖注入机制会根据注解所指定的类型或名称,自动查找相应的Bean。如果找到了匹配的Bean实例,则直接将其注入到目标对象中。如果找到了匹配的Bean定义,但还没有创建Bean实例,则通过Spring的Bean工厂创建Bean实例。一旦获取了Bean实例,Spring的依赖注入机制就会根据注解所指定的属性名称,将相应的属性值注入到目标对象中。
@Resource:这个注解用于在Spring容器中注册Bean。底层实现是通过Java的反射机制,将类中的属性和方法信息提取出来,并通过Spring的Bean定义解析器解析为Bean定义,然后将其注册到Spring容器中。@Resource注解底层实现是通过Java的反射机制和JSR-250规范提供的Resource接口来实现的。JSR-250规范定义了Resource接口,该接口包含了一个名为“name”的属性,可以通过反射机制来获取和设置该属性的值。当使用@Resource注解注入Bean时,可以通过指定“name”属性来指定要注入的Bean的名称,通过调用setter方法将依赖注入到目标对象中,从而实现单例或原型级别的注入。而@Autowired注解底层实现是通过Spring的依赖注入机制来实现的,根据注解所指定的类型或名称,自动将相应的Bean注入到目标对象中。Spring的依赖注入机制是基于单例模式的,因此在默认情况下,@Autowired注解只能注入单例级别的Bean。如果想要注入原型级别的Bean,需要使用@Qualifier注解来指定Bean的名称。@Resource注解的注入顺序是在应用程序启动时完成的,而@Autowired注解的注入顺序则是在应用程序运行时根据需要动态进行的。在实际应用中,建议使用@Autowired注解来注入单例级别的Bean,以保证应用程序的性能和稳定性。
@ComponentScan:这个注解用于指定要扫描的包及其子包。底层实现是通过Java的反射机制,递归扫描指定包及其子包中的类,并将符合条件的类注册到Spring容器中。当一个类被标记为@ComponentScan时,底层实现过程如下:首先,Java的反射机制会递归扫描指定包及其子包中的所有类文件。对于扫描到的每个类,类加载器会将其加载到JVM中。在类加载的过程中,Spring的注解处理器会扫描每个类,并查找是否存在@Component、@Service、@Repository、@Controller等注解。如果某个类被标记了这些注解,注解处理器就会将其注册为Spring容器中的Bean定义。Bean定义包含了类的全限定名、属性等信息,这些信息将被用于创建Bean实例。最后,Spring容器会将Bean定义注册为Spring容器中的Bean,以便在应用程序中使用。
@Conditional:这个注解用于指定条件,当满足条件时才会创建Bean。底层实现是通过Spring的条件注解处理器,根据指定的条件判断是否满足创建Bean的条件。当一个类被标记为@Conditional时,底层实现过程如下:Spring的注解处理器会扫描每个类,并查找是否存在@Conditional注解。如果存在@Conditional注解,条件注解处理器会根据注解所指定的条件判断是否满足创建Bean的条件。如果条件满足,条件注解处理器会继续使用Spring的Bean定义解析器解析该类,并生成Bean定义。Bean定义包含了类的全限定名、属性等信息,这些信息将被用于创建Bean实例。最后,Spring容器会将Bean定义注册为Spring容器中的Bean,以便在应用程序中使用。
@Lazy:这个注解用于延迟加载Bean。底层实现是通过Spring的延迟初始化管理器,在需要使用Bean时才进行初始化。当一个类被标记为@Lazy时,底层实现过程如下:Spring的初始化延迟管理器会根据配置决定是否启用延迟初始化机制。当解析Bean定义时,如果存在@Lazy注解,Bean定义解析器会将该注解传递给初始化延迟管理器。初始化延迟管理器会在Bean的属性被注入之前,将Bean标记为延迟初始化。当需要使用Bean时,Spring的依赖注入机制会跳过该Bean的初始化,直接进行属性注入。当Bean的属性被注入后,初始化延迟管理器会根据需要决定是否进行延迟初始化。如果需要延迟初始化,初始化延迟管理器会创建Bean实例,并将其注册到Spring容器中。
@Primary:这个注解用于指定多个Bean中的一个为主Bean。底层实现是通过Spring的Bean定义解析器,根据注解所指定的标识符将Bean标记为主Bean。@Primary是一个注解,用于在Spring框架中指定一个bean作为特定类型的首选选项。当有多个候选的bean可供选择时,使用@Primary注解来标记某个bean,这样在注入依赖时,优先选择带有@Primary注解的bean。
@Import:这个注解用于导入其他配置类。底层实现是通过Java的反射机制,将类中的属性和方法信息提取出来,并通过Spring的配置解析器解析为Bean定义,然后将其导入到当前配置类中。当一个类被标记为@Import时,底层实现过程如下:首先,Java的反射机制会加载这个类,并获取该类中的属性和方法信息。接着,Spring的配置解析器会解析该类中的注解和属性信息,将其转换为Bean定义。Bean定义包含了类的全限定名、属性等信息,这些信息将被用于创建Bean实例。然后,当前配置类会将@Import注解所指定的配置类中的Bean定义导入到当前配置类中。最后,Spring容器会将导入的Bean定义注册为Spring容器中的Bean,以便在应用程序中使用。
@SpringBootApplication:这个注解是Spring Boot的启动类注解。底层实现是通过Java的反射机制,将类中的属性和方法信息提取出来,并通过Spring Boot的自动配置机制自动配置相关的Bean,然后启动Spring应用程序。当Spring Boot应用启动时,首先会通过Java的类加载机制将@SpringBootApplication注解所在的类加载到JVM中。在类加载完成后,Java的反射机制会被用于查找并处理@SpringBootApplication注解。反射机制可以获取到类的属性、方法等信息,并进行相关操作。通过反射机制获取到类的信息后,Spring Boot会根据这些信息进行自动配置相关的Bean。例如,如果类中定义了DataSource类型的属性,Spring Boot会自动配置一个数据源;如果类中定义了CommandLineRunner类型的静态方法,Spring Boot会自动配置一个CommandLineRunner类型的Bean。完成自动配置后,Spring Boot会调用类的构造函数来初始化应用程序,并启动Spring应用程序。
🌟 6.深入理解ElasticSearch
核心语法、倒排索引、底层原理与分组聚合查询、具备集群高可用实战经验、集群架构原理。有ElasticSearch调优经验,如GC调优、索引优化设置、查询方面优化、数据结构优化、集群架构设计、慢查询优化、可用性优化、性能优化、执行引擎的优化、成本优化、扩展性优化、分析性能问题等。
ElasticSearch是一种基于Java的分布式搜索和分析引擎,它提供了全文搜索、结构化搜索、分析等功能。下面是ElasticSearch的一些核心语法、倒排索引、底层原理和分组聚合查询的介绍。
🍊 核心语法
ElasticSearch支持以下核心语法:
查询语法:支持布尔查询、范围查询、模糊查询、跨字段查询等。
过滤语法:支持布尔过滤、范围过滤、模糊过滤等。
聚合语法:支持统计聚合、范围聚合、分桶聚合等。
排序语法:支持按字段排序、按距离排序等。
🍊 倒排索引
倒排索引是搜索引擎的核心技术,它记录了每个单词在文档中出现的位置信息。在ElasticSearch中,每个文档被分词后,会生成一个倒排索引,该索引包含了每个单词和该单词在哪些文档中出现的信息。在查询时,通过倒排索引可以快速定位到包含特定单词的文档。
🍊 底层原理
ElasticSearch底层原理包括以下几点:
分层架构:ElasticSearch采用了分布式架构,包括客户端、代理节点和数据节点。客户端与代理节点通信,代理节点负责转发请求到数据节点执行。
倒排索引:ElasticSearch使用倒排索引来记录每个单词在文档中出现的位置信息。倒排索引分为段和片段两部分,段包含了单词和该单词在哪些文档中出现的信息,片段包含了单词在具体文档中出现的位置信息。
分布式搜索:ElasticSearch支持分布式搜索,可以将一个查询拆分成多个片段,并分配到不同的节点上执行,从而实现快速查询。
内存缓存:ElasticSearch使用内存缓存来存储最近使用的文档,以提高查询性能。
🍊 分组聚合查询
分组聚合查询是ElasticSearch提供的一种强大的数据分析功能,它可以将查询结果按照某个字段进行分组,并计算出每组的统计信息。以下是分组聚合查询的基本语法:
GET /_search
{
"size": 0,
"aggs" : {
"group_by_field" : {
"terms" : { "field" : "field_name" }
}
}
}
其中,"size"参数指定返回的文档数量,"aggs"参数指定要进行分组聚合的字段,“terms"参数指定要进行分组的字段。此外,还可以使用"top_hits”、“date_histogram”、"range"等参数对分组聚合的结果进行进一步的处理和计算。
🍊 CPU优化
对于提升服务能力来说,升级硬件设备配置是最快速有效的方法之一。在配置Elasticsearch服务器时,考虑CPU型号对性能的影响非常重要。因此,建议选用具有高性能CPU的服务器,例如IntelXeon系列或AMDOpteron系列。此外,为了充分利用多核处理器的优势,可以将Elasticsearch节点放置在不同的物理CPU上,以增加性能。大多数Elasticsearch部署对CPU的要求不高,常见的集群使用2到8个核的机器。如果需要选择更快的CPU或更多的核数,则选择更多的核数更加优越。因为多个内核可以提供更多的并发,这比略微更快的时钟频率更加重要。
注意:CPU的时钟频率是指CPU每秒钟能够执行的时钟周期次数。它通常以赫兹(Hz)为单位表示,如1GHz(1000兆赫)或2.4GHz(2.4千兆赫)。它影响CPU的处理能力和速度,因为更高的时钟频率意味着CPU能够执行更多的指令,并在更短的时间内完成任务。但是,时钟频率并不是唯一决定CPU性能的因素,其他因素如架构、缓存等也会对其性能产生影响。
🍊 内存优化
为了让Elasticsearch具有良好的性能,需要为其分配足够的内存。对于确定所需内存大小,需要考虑预期的数据量和查询负载进行估算。一般情况下,建议将内存分配给JVM堆,以确保Elasticsearch可以尽可能多地利用内存执行操作。但是,对于内存大小的设置,需要遵循以下规则:当机器内存小于64G时,应将JVM堆大小设置为物理内存的50%左右,其中一半留给Lucene,另一半留给Elasticsearch。当机器内存大于64G时,若主要使用场景是全文检索,建议给Elasticsearch Heap分配4~32G的内存,其余内存留给操作系统,以供Lucene使用。若主要使用场景是聚合或排序,并且大多数是数值、日期、地理点和非分析类型的字符数据,建议给Elasticsearch Heap分配4~32G的内存,其余内存留给操作系统,以提供更快的查询性能。若使用场景是聚合或排序,并且都是基于分析类型的字符数据,需要更多的Heap大小,建议机器上运行多个Elasticsearch实例,每个实例保持不超过50%的Elasticsearch Heap设置(但不超过32G),50%以上留给Lucene。此外,禁止使用swap,否则会导致严重的性能问题。为了保证Elasticsearch的性能,可以在elasticsearch.yml中设置bootstrap.memory_lock:true,以保持JVM锁定内存。值得注意的是,由于Elasticsearch构建基于Lucene,Lucene的索引文件segments是存储在单个文件中的,对于操作系统来说,将索引文件保持在缓存中以提高访问速度是非常友好的。
🍊 网络优化
网络带宽是Elasticsearch性能的瓶颈之一,因为基于网络通信的查询和索引操作需要充分利用带宽。若带宽不足,则可能导致操作变慢或超时。在需要传输大量数据时,带宽限制也可能成为性能瓶颈,影响集群响应时间和高并发请求的处理。
除了网络带宽,网络延迟也是Elasticsearch性能瓶颈的重要因素之一。网络延迟可能导致请求和响应之间的延迟或超时,从而影响集群的响应能力。由于Elasticsearch是分布式的,需要在不同节点之间传输数据,因此网络延迟高会降低其查询和索引性能。
此外,网络故障也可能导致Elasticsearch节点之间通信中断,影响集群的可用性和数据一致性。网络拓扑结构也会影响集群的性能,例如,如果两个节点之间的网络距离很远,则同步数据的时间可能会增加,并且可能会增加网络故障的风险。
安全设置(例如加密和身份验证)可能会增加网络负载并影响Elasticsearch性能。因此,可以通过优化网络安全设置来减少性能损失。
为了提高Elasticsearch集群网络的性能和稳定性,需要对以下几个方面进行优化:
(1)带宽限制:当Elasticsearch集群的数据量较大,节点之间的数据交换量较大,可能会出现带宽限制的情况。解决这个问题的方法是增加带宽,可以升级网络硬件设备或购买更高带宽的网络服务。同时,可以使用分片和副本来减少节点之间的数据交换量,从而减少带宽负载。
(2)网络延迟:网络延迟是指在节点之间传输数据时所需要的时间,如果网络延迟过高,会影响Elasticsearch集群的性能。优化网络设置可以降低网络延迟,可以使用更快的网络硬件设备,采用更优化的网络协议,优化Elasticsearch的配置参数等方式来降低网络延迟。使用高速网络设备和协议:如Infiniband或RDMA,可以提高网络传输速度,降低网络延迟。
(3)网络故障:网络故障可能会导致Elasticsearch集群无法正常工作,因此需要采取相应的措施来解决网络故障问题。其中一种方法是采用冗余节点或备份节点来解决网络故障问题。当一个节点无法正常工作时,备份节点可以快速接管工作。同时,可以使用网络监控工具来及时发现并解决网络故障问题。如:Wireshark用于网络故障排除和网络安全分析、Nagios检查主机、服务和网络设备的状态来进行网络监控、Zabbix监控网络设备的状态、性能、流量和带宽使用情况等。
(4)网络拓扑结构:优化网络拓扑结构可以提高Elasticsearch集群的性能。可以采用更合理的网络拓扑结构,例如将Elasticsearch节点放置在相同的数据中心或物理机架上,这可以减少数据同步时间和网络故障的风险。在同一物理机架内或同一数据中心内部,可以使用多个节点来提高集群的性能和容错能力。
(5)网络安全:网络安全是Elasticsearch运行过程中必须关注的问题。可以针对网络安全问题进行优化,采用更快的加密算法,对于不同的数据流采用不同的加密等级。同时,可以使用更快的身份认证算法,例如使用公钥认证等方式来提高Elasticsearch性能。采用分层的网络架构可以提高集群的安全性和性能。例如,在内部网络中使用防火墙和安全网关来保护Elasticsearch集群,并将公共接口放置在外部网络中,以提供对外服务。
(6)部署负载均衡器:通过在Elasticsearch节点之间部署负载均衡器,可以平衡查询负载,避免单个节点负载过重导致性能下降。同时,负载均衡器还可以提高Elasticsearch集群的可用性,当有节点故障时,负载均衡器可以自动将查询请求发送到其他节点,保证服务的连续性。
🍊 磁盘优化
Elasticsearch的性能会受到磁盘延迟的影响,因此为了优化磁盘性能,建议使用高速存储设备如SSD或NVMe,并选择合适的RAID级别。尽可能选择固态硬盘(SSD),因为它比任何旋转介质机械硬盘或磁带写入数据时都会有较大的IO提升,特别是在随机写和顺序写方面。同时,应确保系统I/O调度程序的正确配置,以优化写入数据发送到硬盘的时间,这可以提高写入速度。默认*nix发行版下的调度程序cfq是为机械硬盘优化的,而deadline和noop是为SSD优化的,使用它们可以提高写入性能。
如果使用机械硬盘,可以尝试获取15kRPM驱动器或高性能服务器硬盘来提高磁盘速度。此外,使用RAID0也是提高硬盘速度的有效途径。不需要使用镜像或其他RAID变体,因为Elasticsearch本身提供了副本功能进行备份。如果在硬盘上使用备份功能,对写入速度有较大的影响。最后,避免使用网络附加存储(NAS),因为NAS通常很慢、延时大且是单点故障,而且不如本地驱动器更可靠。因此,建议将数据和索引分开存储,以充分利用不同类型的存储设备。
🍊 计算机系统优化
为了优化Elasticsearch的性能,在以下方面可以采取措施:
(1)禁用不必要的服务和进程,避免占用系统资源,确保Elasticsearch能够充分利用系统资源。
(2)将Elasticsearch节点配置为静态IP,避免动态IP更改导致网络连接问题,从而保证Elasticsearch节点的稳定性。
(3)关闭防火墙或者调整其设置,以避免防火墙对网络流量造成延迟和影响搜索性能。
操作系统对于Elasticsearch的性能有着重要影响,因此为了优化操作系统设置,可以采取以下措施:
首先,可以通过调整内核参数来提高网络性能和文件系统性能,这可以通过修改/sys文件系统下的控制参数来实现。例如,可以通过调整tcp_tw_reuse和tcp_tw_recycle参数来提高TCP连接的处理效率。同时,也可以通过调整文件系统的读写缓存参数来提高文件系统的性能。
其次,可以将文件描述符限制设置为较高的值,以允许Elasticsearch使用更多的文件句柄。可以通过修改/etc/security/limits.conf文件来设置文件描述符限制。这样可以避免因文件描述符数量不足而导致Elasticsearch性能下降的情况。
另外,还可以启用TransparentHugepages和NUMA支持,以提高内存访问效率。TransparentHugepages是一种Linux内核特性,它可以将大页内存自动映射到进程的虚拟内存空间中,从而减少内存管理时的开销。而NUMA是一种多处理器系统结构,它可以将内存和处理器的访问紧密地绑定在一起,从而提高内存访问效率。
综上所述,通过对操作系统进行优化设置,可以有效提升Elasticsearch的性能和稳定性。
🍊 Elasticsearch本身配置参数
为了优化Elasticsearch性能,可以调整以下配置参数:
(1)分配更大的JVM堆内存,以允许Elasticsearch使用更多的内存来缓存索引和搜索结果。
(2)调整索引分片数量,以平衡查询负载和数据分布。
(3)调整索引缓存设置,以缓存查询结果并减少IO操作。
(4)调整搜索线程池大小,以避免搜索请求积压造成性能下降。
🍊 GC调优
根据Elasticsearch官方发布的文档,JDK8附带的HotSpotJVM的早期版本存在可能导致索引损坏的问题,特别是在启用G1GC收集器时。这个问题影响JDK8u40附带的HotSpot版本之前的版本。如果使用较高版本的JDK8或JDK9+,建议使用G1GC,因为它对Heap大对象的优化效果比较明显,目前的项目也是使用G1GC并且运行效果良好。为了启用G1GC,需要修改jvm.options文件中的配置。将原本的如下配置,代码如下:
# 开启使用CMS垃圾回收器
-XX:+UseConcMarkSweepGC
# 初始化CMS垃圾回收器的初始占用阈值为75%
-XX:CMSInitiatingOccupancyFraction=75
# 只使用初始化占用阈值作为CMS垃圾回收器的出发条件
-XX:+UseCMSInitiatingOccupancyOnly
改为:
# 开启 G1 垃圾回收器
-XX:+UseG1GC
# 设置最大垃圾回收时间为 50 毫秒
-XX:MaxGCPauseMillis=50
其中,-XX:MaxGCPauseMillis用于控制预期的最高GC时长,默认值为200ms。如果线上业务对GC停顿敏感,可以适当设置低一些,但是如果设置过小,可能会带来比较高的CPU消耗。需要注意的是,如果集群因为GC导致卡死,仅仅换成G1GC可能无法根本上解决问题,通常需要优化数据模型或者Query。总之,使用G1GC需要慎重,需要根据具体情况进行评估和调整。
🍊 索引优化设置
索引优化是Elasticsearch中的一个重要方面,它主要是通过优化插入过程来提升Elasticsearch的性能。虽然Elasticsearch的索引速度本身已经相当快了,但是具体数据仍可以参考官方的benchmark测试结果来了解。根据测试结果总结,不同的硬件和软件组合会影响Elasticsearch在不同场景下的性能表现。具体如下:
(1)索引速度:单节点下,Elasticsearch可以达到每秒2,000个文档的索引速度;而在分布式集群中,其索引速度可以达到每秒3,000到8,000个文档。使用具有高性能硬件配置的服务器可获得更快的索引速度。而索引速度取决于机器的CPU、内存、网络带宽和磁盘性能等因素。
(2)搜索速度:在单节点上,Elasticsearch的搜索速度可以达到每秒50万到70万次查询;在分布式集群上,随着节点和硬件的增加,其搜索速度可以线性扩展,最高可以达到数百万次查询。使用SSD硬盘的服务器和更高版本的Elasticsearch(如5.x和6.x)也可以提高搜索速度。
(3)响应时间:在所有硬件和软件组合下,Elasticsearch的响应时间都可以控制在1秒以内。使用高性能硬件配置的服务器可以获得更快的响应时间。
(4)内存使用:Elasticsearch使用内存来缓存数据,提高读取和写入速度。虽然内存使用与集群规模和硬件配置有关,但在所有情况下,Elasticsearch的内存使用都可以整体控制在较低的水平。使用高性能硬件配置的服务器可以获得更低的内存使用率。
总之,硬件配置越高,性能指标就越好。但是需要注意的是,硬件配置越高,成本也会越高。因此,在实际使用中,需要根据自己的应用场景和需求,选择合适的硬件配置,以达到最优性能和成本的平衡。
🍊 批量提交
建议在提交大量数据时,采用批量提交(Bulk操作)的方式来提高效率。使用bulk请求时,每个请求的大小不要超过几十兆字节,因为太大会导致内存使用过大。在ELK过程中,比如Logstashindexer向Elasticsearch中提交数据,可以通过调整batchsize来优化性能。需要根据文档大小和服务器性能来设置合适的size大小。如果Logstash中提交文档大小超过20MB,Logstash会将一个批量请求切分为多个批量请求。如果在提交过程中,遇到EsRejectedExecutionException异常,则说明集群的索引性能已经达到极限。此时,可以考虑提高服务器集群的资源,或者减少数据收集速度。例如,只收集Warn、Error级别以上的日志,以降低数据量。需要根据业务规则来决定如何进行优化。
🍊 增加Refresh时间间隔
为了提高Elasticsearch的索引性能,在写入数据的过程中,采用了延迟写入的策略。换言之,数据先写入内存中,当超过默认的1秒(即index.refresh_interval)后,会进行一次写入操作,将内存中的segment数据刷新到磁盘中。只有当数据刷新到磁盘中后,才能对数据进行搜索操作,因此Elasticsearch提供的是近实时搜索功能,而不是实时搜索功能。
如果系统对数据的延迟要求不高,可以通过延长refresh时间间隔来减少segment合并压力,从而提高索引速度。例如在进行全链路跟踪时,可以将index.refresh_interval设置为30秒,从而减少refresh次数。在进行全量索引时,可以将refresh次数临时关闭,将index.refresh_interval设置为-1,数据导入成功后再打开到正常模式,例如设置为30秒。在加载大量数据时,也可以暂时不使用refresh和replicas功能,将index.refresh_interval设置为-1,将index.number_of_replicas设置为0。
综上所述,延迟写入的策略可以提高Elasticsearch的索引性能,适当地调整refresh时间间隔和关闭refresh和replicas功能可以更进一步地提高性能。
🍊 修改index_buffer_size的设置
索引缓冲是一个重要的性能优化工具,通过调整索引缓冲的设置,可以控制内存的分配情况,从而优化节点的索引进程的性能。在Elasticsearch中,索引缓冲的设置是一个全局配置,它会应用于一个节点上所有不同的分片上。可以通过在Elasticsearch的配置文件中设置indices.memory.index_buffer_size参数来控制索引缓冲的大小。这个参数可以接受一个百分比或者一个表示字节大小的值。默认是10%,意味着分配给节点的总内存的10%用来做索引缓冲的大小。如果设置的是百分比,那么这个百分比会被分到不同的分片上。同时,也可以通过设置indices.memory.min_index_buffer_size参数来指定最小的索引缓冲大小,这个参数的默认值是48mb。另外,还可以通过设置indices.memory.max_index_buffer_size参数来控制索引缓冲的最大值。需要注意的是,如果为索引缓冲设置了一个过大的值,可能会导致节点的性能下降。因此,在进行索引缓冲的设置时,需要根据具体的应用场景和硬件配置来进行权衡和调整。
🍊 修改translog相关的设置
为了减少硬盘的IO请求,可以采取一系列操作来优化系统的性能表现。其中一个方法是通过控制数据从内存到硬盘的操作频率来实现。可以通过增加sync_interval时间的设置,来延迟数据写入到硬盘的操作。这样可以降低硬盘的负载,减少硬盘的IO请求。sync_interval的默认时间为5秒,可以通过命令index.translog.sync_interval:5s来设置。
另一个方法是控制translog数据块的大小,以减少flush到lucene索引文件的次数。这样可以进一步减少对硬盘的IO请求,提高系统性能表现。translog数据块的默认大小为512m,可以通过命令index.translog.flush_threshold_size:512mb来设置。需要根据具体的实际情况进行调整。
综上所述,以上两种方法都可以减少硬盘的IO请求,提高系统的性能表现。需要根据系统的实际情况进行优化调整,以达到最优化的性能表现。
🍊 _id字段、_all字段、_source字段、index属性
在使用Elasticsearch中,应该注意一些最佳实践的建议。首先,_id字段不应该自定义,因为这可能会导致版本管理方面的问题。建议使用Elasticsearch提供的默认ID生成策略或使用数字类型ID做为主键。
其次,需要注意使用_all字段和_source字段的场景和实际需求。_all字段包含了所有的索引字段,可以方便做全文检索,但如果不需要进行全文检索,就可以禁用该字段。_source字段存储了原始的document内容,如果没有获取原始文档数据的需求,可以通过设置includes和excludes属性来定义需要存储在_source中的字段,避免不必要的存储开销。因此,在使用_all字段和_source字段时,需要根据实际需求来进行权衡和选择。
此外,合理的配置使用index属性,analyzed和not_analyzed,根据业务需求来控制字段是否分词或不分词。只有groupby需求的字段,应该配置成not_analyzed,以提高查询或聚类的效率。综上所述,对于Elasticsearch的使用,需要根据实际情况来进行配置和选择,以达到最佳实践的效果。
🍊 减少副本数量
Elasticsearch默认拷贝数量为3个,这样的配置虽然能够提高Cluster的可用性,增加查找次数,但是对写入索引的效率也会造成影响。在索引过程中,需要将更新的文档发送到副本节点上,等待副本节点生效后才能返回结果。在实际应用中,建议根据实际需求来调整副本数目。对于业务搜索等关键应用场景,建议仍然保留副本数为3个,以确保数据的可靠性和高可用性;而对于内部ELK日志系统、分布式跟踪系统等应用场景,则完全可以将副本数设置为1个来提高写入效率。因此,在进行Elasticsearch集群的配置时,需要综合考虑集群的可用性、性能和数据可靠性等因素,以实现最优的性价比。
🍊 查询方面优化
Elasticsearch作为业务搜索的近实时查询时,查询效率的优化显得尤为重要。
🎉 路由优化
Elasticsearch作为业务搜索的查询效率的优化显得尤为重要。在查询文档时,Elasticsearch使用公式shard=hash(routing)%number_of_primary_shards来计算文档应该存放到哪个分片中。routing默认为文档的ID,也可以使用用户ID等自定义值。通过对路由进行优化,可以提高查询效率和搜索速度。
🎉 routing查询
查询数据时,如果不带routing参数,则查询过程需要经过分发和聚合两个步骤。首先,请求会被发送到协调节点,协调节点将查询请求分发到每个分片上;随后,协调节点搜集每个分片上的查询结果,进行排序,最后将结果返回给用户。这种方式存在的问题是由于不知道需要查询的数据具体在哪个分片上,因此需要搜索所有分片,增加了查询的时间和资源消耗。
相比较而言,带routing查询可以直接根据routing信息定位到某个分片进行查询,而不需要查询所有的分片,经过协调节点进行排序。以用户查询为例,如果将routing设置为userid,则可以直接查询出数据,大大提高了查询效率。
总之,带routing查询可以加速查询过程,减少了不必要的查询操作,提高了查询效率。
🎉 Filter VS Query
Filter与Query的使用方法是Elasticsearch中最常用的两种查询上下文,但是它们的使用方式是有所不同的。在实际使用中,应该尽可能使用过滤器上下文(Filter)进行查询,而不是使用查询上下文(Query)。
Query上下文主要是用来评估文档与查询语句之间的匹配程度,并为匹配的文档打分。相比之下,过滤器上下文主要是用来检查文档是否与查询语句匹配,它所做的仅仅是返回结果为是或否的答案,无需进行打分等计算过程,从而提高查询的效率和性能。
此外,过滤器上下文的结果还可以进行缓存,即使在多次查询中使用同样的查询条件,也可以直接返回缓存中已经计算得到的结果,避免了重复计算,提高了查询效率。因此,在实际使用中,尽可能使用过滤器上下文进行查询是非常有必要和推荐的。
🎉 深度翻页
在使用Elasticsearch过程中,应注意尽量避免大翻页的出现,因为正常翻页查询都是从from开始size条数据,需要在每个分片中查询打分排名在前面的from+size条数据。这样,协同节点将会收集每个分配的前from+size条数据,一共会受到N*(from+size)条数据,然后进行排序,最终返回其中from到from+size条数据。如果from或size很大,参加排序的数量也会同步扩大很多,导致CPU资源消耗增大。
为了解决这一问题,可以使用Elasticsearch中的scroll和scroll-scan高效滚动的方式,以减小CPU资源消耗。
除此之外,还可以结合实际业务特点,根据文档id大小和文档创建时间的一致有序性,以文档id作为分页的偏移量,并将其作为分页查询的一个条件。这样可以优化查询性能,减少排序参与的数据量,提高查询效率。同时,这也需要根据具体业务情况进行实践验证。
🎉 脚本合理使用
在开发中,脚本(script)的合理使用至关重要。目前脚本使用主要有三种形式:内联动态编译方式、_script索引库中存储和文件脚本存储的形式。其中,内联动态编译方式适合较小的脚本,文件脚本存储形式适合大型脚本,而_script索引库中存储形式则是最常见的使用方式之一。
一般来说,在实际应用中,应尽量采用第二种方式,先将脚本存储在_script索引库中,从而能够提前编译脚本。通过引用脚本id并结合params参数,可以实现模型(逻辑)和数据的分离,同时也更便于脚本模块的扩展和维护。
在使用脚本时,一定要注意场景的选择。对于较小的脚本,可以使用内联动态编译方式;对于大型脚本,则应采用文件脚本存储形式。而对于一些常见的使用场景,最好使用_script索引库中存储形式,以达到更好的编译效果和更高的代码可读性。
总之,脚本的合理使用能够提高开发效率和代码质量,是开发过程中非常重要的一环。
🎉 Cache的设置及使用
在Elasticsearch中,查询性能的优化是非常重要的。其中,QueryCache是Elasticsearch查询的关键性能优化之一,因为它可以减少查询的响应时间。当使用filter查询时,ES会自动使用QueryCache。如果业务场景中的过滤查询比较多,建议将querycache设置大一些,以提高查询速度。可以通过indices.queries.cache.size参数来设置QueryCache的大小,它的默认值为10%。用户可以将其设置为百分比或具体值,如256mb。此外,用户还可以通过设置index.queries.cache.enabled参数来禁用QueryCache。
除了QueryCache之外,Elasticsearch还提供了另一个缓存机制,即FieldDataCache。在聚类或排序场景下,Elasticsearch会频繁使用FieldDataCache。因此,为了提高查询性能,建议用户在这些场景下设置FieldDataCache的大小。indices.fielddata.cache.size参数可用于设置FieldDataCache的大小,用户可以将其设置为30%或具体值10GB。但是,如果场景或数据变更比较频繁,设置cache并不是好的做法,因为缓存加载的开销也是特别大的。
在查询请求发起后,每个分片会将结果返回给协调节点。为了提升查询性能,Elasticsearch提供了一个ShardRequestCache。用户可以通过index.requests.cache.enable参数来开启ShardRequestCache。但是,需要注意的是,shardrequestcache只缓存hits.total、aggregations和suggestions类型的数据,并不会缓存hits的内容。用户也可以通过设置indices.requests.cache.size参数来控制缓存空间大小。默认情况下,其值为1%。
🎉 更多查询优化经验
(1)针对query_string或multi_match的查询,可以采用将多个字段的值索引到一个新字段的方法。在mapping阶段设置copy_to属性,将多个字段的值索引到新字段,这样在进行multi_match查询时可以直接使用新字段进行查询,从而提高查询速度。
(2)对于日期字段的查询,特别是使用now进行查询时,由于不存在缓存,建议从业务需求出发,考虑是否必须使用now进行查询。事实上,利用querycache可以大大提高查询效率,因此也需要对查询缓存进行充分利用。
(3)在设置查询结果集大小时,应根据实际情况进行设置,不能设置过大的值,如将query.setSize设置为Integer.MAX_VALUE。因为Elasticsearch内部需要建立一个数据结构来存放指定大小的结果集数据,设置过大的值会耗费大量的内存资源。
(4)对于聚合查询,需要避免层级过深的aggregation,因为这会导致内存和CPU资源消耗较大。建议在服务层通过程序来组装业务,或者采用pipeline的方式来优化查询。
(5)对于预先聚合数据的方式,可以采用复用预索引数据的技巧来提高聚合性能。例如,如果要根据年龄进行分组,可以预先在索引阶段设置一个age_group字段,将数据进行分类,而不是通过rangeaggregations来按年龄分组。这样可以通过age_group字段来进行groupby操作,从而避免层级过深的aggregation查询,提高聚合性能。
(6)在编写代码时,有时需要在数据集中进行匹配查询。对于大型数据集,经常需要考虑性能问题。为了优化查询性能,一种常见的优化方式是使用filter代替match查询。filter的优点在于它可以缓存,因此可以提高查询的速度。此外,由于match查询通常涉及模糊匹配和转换的过程(fuzzy_transpositions),使用filter可以避免这种无意义的查询操作,进一步提高查询效率。然而,需要注意的是,使用filter来代替match查询的优化是有限的。对于一些复杂的查询,仍需要使用match查询来实现。因此,在优化查询性能时,需要综合考虑使用不同的查询方式,以达到最优化的效果。
(7)对于数据类型的选择需要理解Elasticsearch底层数据结构,在Elasticsearch2.x时代,所有数字都是按照keyword类型进行处理,这意味着每个数字都会建立一个倒排索引。虽然这种处理方式可以提高查询速度,但是在执行范围查询时,例如type>1 and type<5,需要将查询转换为type in (1,2,3,4,5),这显著增加了范围查询的难度和耗时。随后,Elasticsearch进行了优化,在处理integer类型数据时采用了一种类似于B-tree的数据结构,即Block k-d tree,以加速范围查询。Block k-d tree被设计用于多维数值字段,并可用于高效过滤地理位置等数据。此外,它还可用于单维度的数值类型。对于单维度的数据,Block k-d tree的实现与传统的B-tree有所不同。它对所有值进行排序,并反复从中心进行切分,生成具有类似B-tree结构的索引。该结构的叶子节点存储的不是单个值,而是一个值的集合,也就是所谓的一个Block。每个Block最多包含512至1024个值,以确保值在Block之间均匀分布。这种数据结构大大提高了范围查询的性能,因为在传统的索引结构中,满足查询条件的文档集合并不是按照文档ID顺序存储的,而是需要构造一个巨大的bitset来表示。而使用Block k-d tree索引结构,则可以直接定位满足查询条件的叶子节点块在磁盘上的位置,然后顺序读取,显著提高了范围查询的效率。
(8)在使用Block k-d tree的数据结构进行范围查询时,磁盘读取是顺序读取,因此对范围查询有很大的优势。然而在某些场景下使用PointRangeQuery会非常慢,因为它需要将满足查询条件的docid集合拿出来单独处理。这个处理过程在org.apache.lucene.search.PointRangeQuery#createWeight方法中可以读取到,主要逻辑是在创建scorer对象的时候,顺带先将满足查询条件的docid都选出来,然后构造成一个代表docid集合的bitset。在执行advance操作时,就会在这个bitset上完成。由于这个构建bitset的过程类似于构造Query cache的过程,所有的耗时都在build_scorer上。因此,使用PointRangeQuery在该场景下会非常慢。另外,对于term查询,如果数值型字段被转换为PointRangeQuery,也会遇到同样的问题。在这种情况下,无法像Postlings list那样按照docid顺序存放满足查询条件的docid集合,因此无法实现postings list上借助跳表做蛙跳的操作。
(9)对于像isDeleted这样只有两个可能取值(是/否)的字段,Elasticsearch会自动根据倒排索引的文档数和Term的文档频率来判断是否使用倒排索引进行查询。如果该Term的文档频率太高,超过了一定的阈值,Elasticsearch会认为使用倒排索引查询的效率不如使用全表扫描,因此会放弃使用倒排索引,转而使用全表扫描。这个阈值可以通过设置index.max_terms_count来调整。如果该字段在查询时频繁被使用,可以考虑将其映射为一个不分词、不使用倒排索引的字段,这样可以避免在查询时产生额外的开销。
(10)当进行多个term查询并列的时候,在Elasticsearch中执行顺序不是由查询时写入的顺序决定的。实际上,Elasticsearch会根据每个filter条件的区分度来评估执行顺序,将高区分度的filter条件先执行,以此可以加速后续的filter循环速度,从而提高查询效率。举例来说,如果一个查询条件的结果集很小,那么Elasticsearch就会优先执行这个条件。为了实现这一点,当使用term进行查询时,每个term都会记录一个词频,即这个term在整个文档中出现的次数。这样Elasticsearch就能判断每个term的区分度高低,从而决定执行顺序。综上所述,当使用多个term查询时,Elasticsearch会根据每个filter条件的区分度来决定执行顺序,以此提高查询效率。
(11)为了快速查找索引 Term 的位置,可以采用哈希表作为索引表来提高查找效率。同时,为了减少倒排链的查询和读取时间,可以采用 RoaringBitmap 数据结构来存储倒排链,并且结合 RLE Container 实现倒排链的压缩存储。这样,可以将倒排链的合并问题转化为排序问题,从而实现批量合并,大大降低了合并的性能消耗。另外,根据数据的分布,自动选择 bitmap/array/RLE 容器,可以进一步提高 RLE 倒排索引的性能。
(12)在增量索引场景下,如果增量索引的变更量非常大,会导致频繁更新内存 RLE 倒排索引,进而带来内存和性能的消耗。为了解决这个问题,可以将 RLE 倒排索引的结构固化到文件中,在写索引时就可以完成对倒排链的编码,避免了频繁更新内存索引的问题。这种做法可以提升索引的写入性能,同时保证了查询的高效性和稳定性。
通过开启慢查询配置定位慢查询
一般而言,当 Elasticsearch 查询所花费的时间超过一定阈值时,系统会记录该查询的相关信息并将其记录在慢查询日志中以供查看。在实际的应用场景中,可以通过对慢查询日志的分析来确定哪些查询较为耗时,从而帮助进行性能优化。通过开启慢查询配置可以快速定位Elasticsearch查询速度缓慢的问题,并进行相应的性能调优,提高系统的查询效率和用户体验。对于 ElasticSearch这类搜索引擎而言,通过设置开启慢查询日志可以快速定位查询速度较慢的原因。
🍊 数据结构优化
针对 Elasticsearch 的使用场景,需要根据实际情况进行文档数据结构的设计,以便更好地发挥 Elasticsearch 的搜索和分析能力。在设计文档结构时,需要将使用场景作为主要考虑因素,去掉不必要的数据。这有助于减少索引的大小、提高搜索和分析的效率。
在实际使用 Elasticsearch 进行数据存储和检索时,应根据具体场景灵活使用索引和文档类型,合理划分数据和定义字段。这有助于提高搜索和分析的精度和效率,并能够满足不同场景的需求。
因此,在使用 Elasticsearch 时,必须深入了解应用场景,进行合理的文档数据结构设计,去掉不必要的数据,提高搜索和分析的效率和精度,最终实现更好的业务效果和用户体验。
🎉 减少不需要的字段
如果Elasticsearch作为业务搜索服务的一部分,应该避免将一些不需要用于搜索的字段存储到Elasticsearch中。这种做法能够节省空间,同时在相同的数据量下,也能提高搜索性能。此外,应该避免使用动态值作为字段,因为动态递增的mapping可能会导致集群崩溃。同样,需要控制字段的数量,业务中不使用的字段应该不要索引。控制索引的字段数量、mapping深度、索引字段的类型,这是优化Elasticsearch性能的关键之一。
Elasticsearch在默认情况下设置了一些关于字段数、mapping深度的限制,即index.mapping.nested_objects.limit、index.mapping.total_fields.limit和index.mapping.depth.limit。其中,index.mapping.nested_objects.limit限制了Elasticsearch中嵌套对象的数量,它的默认值为10000。index.mapping.total_fields.limit限制了Elasticsearch中字段的数量,它的默认值为1000。index.mapping.depth.limit限制了Elasticsearch中mapping的嵌套深度,它的默认值为20。在实际使用中,根据业务需求可以适当调整这些限制值以获得更好的性能。
🎉 Nested Object vs Parent/Child
建议在mapping设计阶段尽量避免使用nested或parent/child的字段,因为这些查询性能较差,能不用就不用。如果必须使用nestedfields,要保证nestedfields字段不能过多,因为针对1个document,每一个nestedfield,都会生成一个独立的document,这将使doc数量剧增,影响查询效率,尤其是JOIN的效率。
默认Elasticsearch的限制是每个索引最多有50个nestedfields,如果需要增加或减少nestedfields的数量,可以修改配置文件中的index.mapping.nested_fields.limit参数。对于常规的文档存储和查询,使用NestedObject可以保证文档存储在一起,因此读取性能高;相反,对于需要独立更新父文档或子文档的情况下,可以使用Parent/Child结构,这样可以保证父子文档可以独立更新,互不影响;但是为了维护join关系,需要占用部分内存,读取性能较差。因此,在选择使用NestedObject还是Parent/Child结构时,需要根据具体的场景进行选择,子文档偶尔更新且查询频繁时可以选择NestedObject,而子文档更新频繁时可以选择Parent/Child结构。
🎉 静态映射
为确保集群的稳定性,在使用Elasticsearch的过程中,推荐选择静态映射方式。静态映射不仅能够保证数据类型的一致性,还能够提高查询效率。相反,如果使用动态映射,可能会导致集群崩溃,并带来不可控制的数据类型,从而影响业务的正常运行。此外,在Elasticsearch中,数据的存储类型分为匹配字段和特征字段两种。匹配字段用于建立倒排索引以进行query匹配,而特征字段如ctr、点击数、评论数等则用于粗排。因此,在设计索引时需要根据不同的功能选择不同类型的字段进行建立倒排索引,以满足业务的需求和提高查询效率。综上所述,静态映射是建立Elasticsearch稳定、高效的必要条件,而动态映射的使用应在必需时加以限制。
🎉 document 模型设计
MySQL经常需要进行一些复杂的关联查询,但是在Elasticsearch中并不推荐使用复杂的关联查询,因为一旦使用会影响性能。因此,最好的做法是在Java系统中先完成关联,将关联好的数据直接写入Elasticsearch中。这样,在搜索时,就不需要使用Elasticsearch的搜索语法来完成关联搜索。
在设计document模型时,需要非常重视,因为在搜索时执行复杂的操作会影响性能。Elasticsearch支持的操作有限,因此需要避免考虑使用Elasticsearch进行一些难以操作的事情。如果确实需要使用某些操作,最好是在document模型设计时就完成。此外,需要尽量避免使用复杂操作,例如join/nested/parent-child搜索,因为它们的性能都很差。
🍊 集群架构设计
为了提高Elasticsearch服务的整体可用性,需要合理的部署集群架构。Elasticsearch集群采用主节点、数据节点和协调节点分离的架构,即将主节点和数据节点分开布置,同时引入协调节点,以实现负载均衡。在5.x版本以后,数据节点还可进一步细分为Hot-Warm的架构模式。
🎉 主节点和数据节点
在Elasticsearch配置文件中,有两个非常重要的参数,分别是node.master和node.data。这两个参数配合使用,可以提高服务器的性能。主节点配置node.master:true和node.data:false,表示该节点只作为一个主节点,不存储任何索引数据。推荐每个集群运行3个专用的Master节点,以提高集群的弹性。在使用时,还需设置discovery.zen.minimum_master_nodes参数为2,以避免脑裂的情况。因为三个主节点仅负责集群的管理,不包含数据、不进行搜索和索引操作,因此它们的CPU、内存和磁盘配置可以比数据节点少很多。
数据节点配置node.master:false和node.data:true,只作为一个数据节点,专门用于存储索引数据,实现功能单一,降低资源消耗率。Hot-Warm架构是将数据节点分成热节点和暖节点,热节点只保存最新的数据,暖节点则保存旧的数据,以实现不同数据的不同存储需求。
引入协调节点可以实现负载均衡,减少节点间的通信压力,提升服务的整体性能。在协调节点上可以运行诸如Kibana、Logstash、Beats等工具,以进行数据可视化、数据采集等操作。在配置协调节点时,还需设置discovery.zen.minimum_master_nodes参数为2,避免脑裂的情况。
总之,合理的部署Elasticsearch集群架构,可以提高服务的整体可用性,减少节点间的通信负担,降低资源消耗率,优化服务的整体性能。
🎉 hot节点和warm节点
hot节点主要是用于存储索引数据并保存最近频繁被查询的索引。由于索引是一项CPU和IO密集型操作,因此建议使用SSD磁盘类型来保持高性能的写入操作。同时,为了保证高可用性,建议至少部署3个最小化的hot节点。如果需要增加性能,可以增加服务器数量。要将节点设置为hot类型,elasticsearch.yml应该包含以下配置:node.attr.box_type:hot。对于针对指定索引操作,可以通过设置index.routing.allocation.require.box_type:hot使其将索引写入hot节点。
warm节点主要是处理大量不经常访问的只读索引的设计。由于这些索引是只读的,因此warm节点倾向于挂载大量普通磁盘来替代SSD。内存和CPU的配置应该与hot节点保持一致,节点数量一般也应该大于或等于3。要将节点设置为warm类型,elasticsearch.yml应该包含以下配置:node.attr.box_type:warm。同时,也可以在elasticsearch.yml中设置index.codec:best_compression以保证warm节点的压缩配置。当索引不再频繁查询时,可以使用index.routing.allocation.require.box_type:warm将索引标记为warm,从而确保索引不写入hot节点,以便将SSD磁盘资源用于处理更为关键的操作。一旦设置了该属性,Elasticsearch会自动将索引合并到warm节点。
🎉 协调节点
协调(coordinating)节点是分布式系统中的一个节点,用于协调多个分片或节点返回的数据,进行整合后返回给客户端。该节点不会被选作主节点,也不会存储任何索引数据。在Elasticsearch集群中,所有的节点都有可能成为协调节点,但可以通过设置node.master、node.data、node.ingest都为false来专门设置协调节点。协调节点需要具备较好的CPU和较高的内存。在查询时,通常涉及从多个node服务器上查询数据,并将请求分发到多个指定的node服务器,对各个node服务器返回的结果进行汇总处理,最终返回给客户端。因此,协调节点在查询负载均衡方面发挥了重要的作用。除此之外,可以通过设置node.master和node.data的值来特别指定节点的功能类型,如:node.master:false和node.data:true,该节点仅用于数据存储和查询,node.master:true和node.data:false,该节点仅用于协调请求等。设置节点的功能类型可以使其功能更加单一,从而降低其资源消耗率,提高集群的性能。
🎉 关闭data节点服务器中的http功能
在Elasticsearch集群中,关闭data节点服务器中的http功能是一种有效的保障数据安全和提升服务性能的方法。具体实现方式是对所有的数据节点进行http.enabled:false的配置参数设置,同时不安装head、bigdesk、marvel等监控插件,这样data节点服务器只需处理索引数据的创建、更新、删除和查询等操作。而http服务可以在非数据节点服务器上开启,相关监控插件也可以安装到这些服务器上,用于监控Elasticsearch集群的状态和数据信息。通过这种方法,可以在保证数据安全的前提下,提升Elasticsearch集群的服务性能。
🎉 一台服务器上只部署一个node
在一台物理服务器上,可以通过设置不同的启动端口来启动多个node服务器节点。然而,由于服务器的CPU、内存、硬盘等资源是有限的,当在同一台服务器上启动多个node节点时,会导致资源的竞争和争夺,从而影响服务器性能。因此,建议在进行服务器节点部署时,将不同的node节点部署在不同的物理服务器上,从而实现资源的充分利用,提高服务器性能和可靠性。同时,为了确保多个node节点之间的通信和协调,可以使用负载均衡器等技术手段来实现节点间的负载均衡和故障转移,从而保障应用程序的稳定运行。
🎉 集群分片设置
在Elasticsearch中,一旦创建好索引后,就不能再调整分片的设置。由于一个分片对应于一个Lucene索引,而Lucene索引的读写会占用大量的系统资源,因此分片数不能设置过大。因此,在创建索引时,合理配置分片数是非常重要的。一般来说,应当遵循以下原则:
(1)控制每个分片占用的硬盘容量不超过Elasticsearch的最大JVM堆空间设置,通常不应超过32GB。因此,如果索引的总容量在500GB左右,分片大小应在16个左右。当然,最好同时考虑原则2。
(2)考虑节点数量。通常情况下,每个节点对应一台物理机。如果分片数过多,即大大超过了节点数,很可能会导致在某个节点上存在多个分片。一旦该节点发生故障,即使保持了一个以上的副本,仍然有可能导致数据丢失,从而无法恢复整个集群。因此,一般都设置分片数不超过节点数的三倍。
🍊 可用性优化
在可用性方面,Elasticsearch原生版本存在三个问题:
(1)系统健壮性不足:系统在面对意外情况时无法保持正常运行,具体表现为系统容易导致集群雪崩和节点OOM。这种情况在大流量的情况下尤为明显,此时系统的负载会变得非常高,容易导致节点内存耗尽,甚至导致集群崩溃。造成这种情况的主要原因是内存资源不足和负载不均。具体来说,内存资源不足可能是由于系统中存在内存泄漏或者内存管理不当等引起的。而负载不均则可能是由于集群中的节点在处理任务时,存在一些节点负载过高,而另一些节点负载过轻的情况。为了解决这个问题,需要采取一些优化措施,以提升系统的健壮性和稳定性。具体来说,可以优化服务限流和节点均衡策略。限流策略的作用是控制系统的访问量,防止系统因为大量请求而导致崩溃或者响应变得异常缓慢。节点均衡策略则是通过对任务进行分配,以使得每个节点的负载均衡,避免一些节点负载过高,而另一些节点负载过轻的情况。这些优化措施可以有效增强系统的健壮性,使得系统更加稳定可靠。
(2)容灾方案欠缺:尽管Elasticsearch自身提供了副本机制,以确保数据的安全性,但是对于涉及多个可用区的容灾策略,需要云平台额外实现。此外,即使在存在副本机制和跨集群复制的情况下,仍然需要提供低成本的备份回滚能力,以应对可能存在的误操作和数据删除的风险。针对这些问题,建议采取以下措施:首先,对于多可用区容灾方案的实现,可以考虑采用云平台提供的跨可用区副本和快照备份功能,以确保数据可靠性和可用性。其次,可以部署多个集群,实现数据的跨集群复制,以进一步提高数据的安全保障。同时,需要在数据备份和回滚方面做好充分的准备,确保在发生误操作或数据删除时能够迅速恢复数据。
(3)内核Bug:Elasticsearch是一种开源搜索引擎,其内核存在一些Bug,可能会影响其可用性。为了解决这些问题,Elasticsearch修复了一系列与内核可用性相关的问题,包括Master任务堵塞、分布式死锁、滚动重启速度慢等问题。此外,为了确保用户能够及时获得修复后的版本,Elasticsearch及时提供了新版本给用户升级。这些措施充分展示了Elasticsearch对用户可用性的关注,并且以负责任的方式解决问题。这些改进将有助于提升Elasticsearch的性能和可靠性,进一步满足用户在搜索领域的需求。
接下来,将针对用户在可用性层面常遇到的两类问题展开分析:
(1)当高并发请求过多时,会导致集群崩溃的问题。为了解决这个问题,可以采用一些方法来提升集群的吞吐能力。其中,可以优化集群的配置,例如增加硬件资源、提升网络带宽、调整线程池大小等。另外,可以采用异步I/O方式来提高请求的处理效率,从而缓解集群压力。此外,负载均衡技术也是一个值得推荐的方法,通过将请求分配到不同的节点上,可以避免某些节点过载而导致集群崩溃的情况发生。总之,需要采取多种方法综合应对高并发请求的问题,从而提升集群的稳定性和吞吐能力。
(2)当进行单个大查询时,很容易出现节点因负载过大而崩溃的情况。为了解决这个问题,可以采用一些优化措施。首先是数据分片和副本,这可以将数据分散到多个节点上,减少单个节点的负载,同时保证数据的可靠性和高可用性。其次是搜索建议,它可以根据用户输入的关键词提供相关的搜索建议,减少用户不必要的查询请求。最后是聚合结果优化,它可以对查询结果进行聚合,减少不必要的数据传输和计算,提高查询效率和稳定性。通过这些优化措施,可以有效地减轻单个查询对节点的负载,提升系统的查询效率和稳定性,达到更好的用户体验和服务质量。
🍊 高并发请求压垮集群
高并发请求是一种常见的场景,可能会导致集群崩溃。例如,早期内部的一个日志集群,其中写入量在一天内突然增加了5倍,导致集群中的多个节点的Old GC卡住而脱离集群,集群变成了RED状态,写入操作停止了。这个场景可能会对集群造成很大的损失。对于挂掉的节点,进行内存分析后发现,大部分内存都被反序列化前后的写入请求所占用。这些写入请求是堆积在集群的接入层位置上的。接入层是指用户的写入请求先到达其中一个数据节点,称之为数据节点。然后由该协调节点将请求转发给主分片所在节点进行写入,主分片写入完毕再由主分片转发给从分片写入,最后返回给客户端写入结果。从内存分析结果看,这些堆积的位置导致了节点的崩溃,因此根本原因是协调节点的接入层内存被打爆。
经过问题原因的分析,制定了针对高并发场景下的优化方案。这个方案包括两个关键点:加强对接入层的内存管理和实现服务限流。为了避免集群崩溃,需要确保接入层内存不会被输入请求打爆,因此需要加强内存管理。在实现服务限流的方面,需要一个能够控制并发请求数量,并且能够精准地控制内存资源的方案。这个方案还要具有通用性,能够作用于各个层级实现全链限流。
🍊 服务限流
一般情况下,数据库的限流策略是从业务端或者独立的代理层配置相关的业务规则,进行资源预估等方式进行限流。但是这种方式适应能力较弱、运维成本高、业务端很难准确地预估资源消耗。原生版本本身也有限流策略,但是单纯地基于请求数的限流不能控制资源使用量,而且只作用于分片级子请求的传输层,对于接入层无法起到有效的保护作用。
因此,优化方案是基于内存资源的漏桶策略。将节点JVM内存作为漏桶的资源,当内存资源足够的时候,请求可以正常处理,当内存使用量到达一定阈值的时候分区间阶梯式平滑限流,处理中的请求和merge操作都可以得到保证,从而保证节点内存的安全性。这个方案不仅可以控制并发数,还可以控制资源使用量并且具有通用性,可以应用于各个层级实现全链限流。
在限流方案中,一个重要的挑战是如何实现平滑限流。采用单一的阈值限流很容易出现请求抖动的情况,例如请求一上来就会立即触发限流,因为内存资源不足,而稍微放开一点请求量又会迅速涌入,使内存资源再次极度紧张。因此,通过设置高低限流阈值区间、基于余弦变换实现请求数和内存资源之间的平滑限流方案。在该区间中,当内存资源足够时,请求通过率达到100%;当内存到达限流区间时,请求通过率逐步下降。而当内存使用量下降时,请求通过率也会逐步上升,而不是一下子放开。经过实际测试,平滑的区间限流能够在高压力下保持稳定的写入性能。平滑限流方案是对原生版本基于请求数漏桶策略的有效补充,作用范围更广泛,能覆盖协调节点、数据节点的接入层和传输层。但需要说明的是,该方案并不会替代原生的限流方案,而是对其进行有效的补充。
🍊 单个大查询打挂节点
在某些分析场景中,需要进行多层嵌套聚合,这可能导致返回的结果集非常大,因此可能导致某个请求将节点打挂。在这种聚合查询流程中,请求首先到达协调节点,然后被拆分为分片级子查询请求,然后发送给目标分片所在的数据节点进行子聚合。最后,协调节点将所有分片结果收集并进行归并、聚合、排序等操作。然而,这个过程中存在两个主要问题点。
第一个问题是,当协调节点大量汇聚结果并反序列化之后,可能会导致内存膨胀。这可能是由于结果集太大,或者节点内存不足等原因造成的。
第二个问题是,二次聚合可能会产生新的结果集,这可能导致内存爆炸。
为了解决上述单个大查询的问题,可以采用以下五个优化方案。
首先,针对内存开销问题,可以通过增加节点内存大小或将查询结果进行分批处理来优化。这样能够在降低内存使用率的同时,提高查询效率。
接着,针对内存浪费严重的写入场景,优化方案主要是实现弹性的内存buffer,并对于读写异常的请求及时进行内存回收。要注意,这里所提到的内存回收策略并不是指GC策略。JVMGC债务管理主要评估JVMOldGC时长和正常工作时长的比例来衡量JVM的健康情况,特殊情况下会重启JVM以防止长时间hang死,这与内存回收策略是两个不同的方面。通过内存利用率优化,整个公有云的Elasticsearch集群的可用性得到了提升,达到了4个9。内存利用率提升了30%,在高压力场景下节点稳定性更强,基本能保证节点不会OOM,集群也不会雪崩。总的来说,内存利用率,内存回收策略以及JVMGC债务管理都是优化内存利用率的重要方面,它们能够提高系统吞吐量,减少节点OOM的发生,保障系统的稳定性和可用性。通过对这些方面的优化,能够有效地提高系统的性能表现,使系统更加健康和稳定。
其次,在进行聚合操作时,需要特别注意减少中间结果的存储和传输。对于大规模数据集的查询,优先考虑使用分布式计算框架,如Apache Spark等。
然后,在数据库管理中,单个查询内存限制是一个非常有用的功能。当一个查询过于庞大时,其会占用大量的内存资源,从而影响其他所有请求的响应时间。通过设置单个查询内存限制,可以有效地控制查询的内存使用量,从而保证整个数据库系统的正常运行。除此之外,滚动重启速度优化也是一个非常实用的功能。尤其是在大规模集群环境下,单个节点的重启时间往往较长,如果要重启整个集群,可能会导致整个系统长时间处于不可用状态。通过优化滚动重启速度,可以将单个节点的重启时间从10分钟降至1分钟以内,大幅缩短了重启时间,从而提高了系统的可用性。值得一提的是,这个优化已经在7.5版本中被合并了,因此用户不需要再自己手动进行配置。如果遇到大集群滚动重启效率问题,可以关注此功能,以提高数据库系统的可靠性和稳定性。
最后,第三个优化方案的重点是内存膨胀预估加流式检查。该方案主要分为两个阶段:第一阶段在协调节点接收数据节点返回的响应结果反序列化之前做内存膨胀预估,并在内存使用量超过阈值时直接熔断请求;第二阶段在协调节点reduce过程中,流式检查桶数,每增加固定数量的桶检查一次内存,如果超限则直接熔断。这样用户不再需要关心最大桶数,只要内存足够就能最大化地满足业务需求。不足之处是大请求还是被拒掉了,但是可以通过官方已有的batch reduce的方式缓解,即每收到部分子结果就先做一次聚合,这样能降低单次聚合的内存开销。该方案已经提交给官方并合并了,将在最近的7.7.0版本中发布。
🍊 性能优化
性能优化的场景可以分为写入和查询两个部分。在写入方面,主要包括海量时序数据场景,如日志和监控,通常能够实现千万级别的吞吐。带有id的写入会导致性能衰减一倍,因为需要首先查询记录是否存在。在查询方面,主要包括搜索场景和分析场景。搜索服务需要高并发并且具有低延迟;而聚合分析主要涉及大型查询,需要大量的内存和CPU开销。
从性能影响面的角度来看,硬件资源和系统调优通常是直接可控的,例如资源不足时可以进行扩容,调整参数深度来进行调优等。然而,存储模型和执行计划通常涉及内核优化,因此普通用户难以直接进行调整。接下来,将重点介绍存储模型和执行计划的优化。
存储模型的优化是一个关键问题。Elasticsearch底层Lucene基于LSM Tree的数据文件。原生默认的合并策略是按文件大小相似性合并,一次性固定合并10个文件,采用近似分层合并。这种合并方式最大的优点是效率高,可以快速降低文件数。但是,文件不连续会导致查询时的文件裁剪能力较弱,例如查询最近1小时的数据,有可能会将1小时的文件拆分到几天前的文件中,进而增加了必须检索的文件数量。业界通常采用解决数据连续性的合并策略,例如基于时间窗口的合并策略,如以Cassandra、HBase为代表的策略。其优点在于数据按时间序合并,查询效率高,还可以支持表内TTL。缺点是仅适用于时序场景,并且文件大小可能不一致,从而影响合并效率。另一类策略由LevelDB、RocksDB为代表的分层合并策略构成,一层一组有序,每次抽取部分数据向下层合并,优点在于查询高效。但如果相同的数据被合并多次,这将影响写入吞吐。
最后是优化合并策略,其目标是提高数据连续性、收敛文件数量,提升文件裁剪的能力以提高查询性能。实现策略是按时间序分层合并,每层文件按创建时间排序。除了第一层外,所有层次都按照时间序和目标大小进行合并,而不是固定每次合并文件数量,保证了合并效率。对于少量未合并的文件和冷分片文件,采用持续合并策略,将超过默认5分钟不再写入的分片进行持续合并,并控制合并并发和范围,以降低合并成本。
🍊 执行引擎的优化
在Elasticsearch中,有一种聚合叫做Composite聚合,它支持多字段的嵌套聚合,类似于MySQL的group by多个字段,同时也支持流式聚合,即以翻页的形式分批聚合结果。使用Composite聚合时,只需要在查询时聚合操作下面指定composite关键字,并指定一次翻页的长度和group by的字段列表,每次拿到的聚合结果会伴随着一个after key返回,下一次查询可以拿着这个after key查询下一页的结果。
Composite聚合的实现原理是利用一个固定size的大顶堆,size就是翻页的长度,全量遍历一把所有文档迭代构建这个基于大顶堆的聚合结果,最后返回这个大顶堆并将堆顶作为after key。第二次聚合时,同样的全量遍历一把文档,但会加上过滤条件排除不符合after key的文档。然而,这种实现方式存在性能问题,因为每次拉取结果都需要全量遍历一遍所有文档,并未实现真正的翻页。
为了解决这个问题,提出了一种优化方案,即利用index sorting实现after key跳转以及提前结束(earlytermination)。通过index sorting,可以实现数据的有序性,从而实现真正的流式聚合,大顶堆仍然保留,只需要按照文档的顺序提取指定size的文档数即可快速返回。下一次聚合时,可以直接根据请求携带的afterkey做跳转,直接跳转到指定位置继续向后遍历指定size的文档数即可返回。这种优化方案可以避免每次翻页全量遍历,大幅提升查询性能。
在Elasticsearch7.6版本中,已经实现了覆盖数据顺序和请求顺序不一致的优化场景。该版本在性能层面进行了全面优化,从底层的存储模型、执行引擎、优化器到上层的缓存策略都有相应的提升。具体来说,该版本在存储模型方面采用了更加高效的数据结构和算法,以减少磁盘I/O、内存消耗等问题,提高读写性能。在执行引擎方面,优化了查询和聚合操作的执行过程,使其能快速响应请求并返回数据。而在优化器方面,针对不同的查询场景进行了优化,以减少计算量,提高查询效率。最后,在缓存策略方面,采用了更加智能的缓存机制,以加速常用请求的响应速度。以上这些优化措施的整合,实现了对覆盖数据顺序和请求顺序不一致的场景的优化,并带来了更高效、更可靠的Elasticsearch体验。
🍊 成本优化
在大规模数据场景下,优化成本是一个非常重要的问题。在此过程中,需要重点关注集群的 CPU、内存和磁盘三个方面。根据实际情况,这三个方面的成本占比一般为1比4比8。也就是说,磁盘和内存成本占比相对较高,需要着重考虑。举个例子,一般的16核64GB,2-5TB磁盘节点的成本占比也大致如此。因此,在成本优化过程中,主要的瓶颈就在于磁盘和内存的使用。在实际操作中,可通过对磁盘和内存的使用进行优化,以降低成本,提高效率。
成本优化的主要目标是存储成本和内存成本。
🎉 存储成本优化
Elasticsearch单个集群能够处理千万级别的写入操作,但实现千万每秒的写入量需要考虑多方面因素,如硬件配置、索引设计、数据量和查询复杂度等,而业务需要保留至少半年的数据供查询。
假设单集群平均写入速度为1000万OPS,意味着在半年的时间内,共有60 * 60 * 24 * 180 = 15552000秒。每个文档大小为50Byte,且基于高可用需要2个副本,因此计算公式为:1000万(OPS) * 86400(秒) * 180(天) * 50Byte(平均文档大小) * 2(副本)等于14PB。即此集群需要14PB的存储空间。假设每台物理机的内存和硬盘都能够完全用于Elasticsearch存储,那么:1PB = 1024TB,14PB = 14 * 1024 = 14336TB。然而,一个物理机可以存储多少数据,取决于该数据的复杂度、索引方式、查询频率等因素。假设平均每台物理机可以存储800GB数据,则此集群需要的物理机数量为:14336TB ÷ 0.8TB/台 = 17920台。这么多的物理机数量的成本远远超出了业务成本预算。因此,需要采用其他方式,在不牺牲性能的情况下减少存储需求,以适应业务预算。
为了提高对Elasticsearch系统的效率和成本效益,可以采取多种优化措施。首先,可以通过调研业务数据访问频率,将历史数据进行冷热分离,将冷数据放入HDD中来降低存储成本。同时,索引生命周期管理可用于数据搬迁,将冷数据盘利用多盘策略提高吞吐和数据容灾能力。此外,超冷数据可以通过冷备到腾讯云对象存储COS中来降低成本。 其次,通过分析数据访问特征,可以采用Rollup方案降低历史数据的精度并降低存储成本。Rollup方案利用预计算来释放原始细粒度数据,例如将秒级数据聚合成小时级和天级,以方便展示跨度较长的跨度报表。Rollup方案还显著降低存储成本和提高查询性能。Rollup优化方案主要基于流式聚合加查询剪枝结合分片级并发来实现高效性。分片级并发可以通过添加routing来实现,让相同的对象落到相同的分片内,并能实现分片级并发。此外,通过对Rollup任务资源预估,并感知集群的负载压力来自动控制并发度,从而避免对集群整体的影响。综上所述,通过冷热分离、索引生命周期管理、多盘策略、对象存储和Rollup方案等手段,可以从架构层对Elasticsearch进行优化,实现同时满足业务需求和成本效益。
🎉 内存成本优化
目前,很多情况下堆内存使用率过高,而磁盘使用率相对较低。FST是一种倒排索引,它通常常驻内存且占用较大的内存比例。为了节省内存空间并保持快速访问,FST使用了自适应前缀编码技术,但这也导致了在查询时需要解压缩FST,从而占用大量的堆内存空间。因此,将FST移至堆外(off-heap)并按需加载FST可以显著提高堆内内存利用率并降低垃圾回收开销,从而提高单个节点对磁盘的管理能力。
具体来说,在每10TB的磁盘中,FST需要10GB到15GB的内存来存储索引。为了减小内存占用,可以将FST从堆内存中移至堆外,这种方式可以单独管理并减轻对JVM垃圾回收的影响。除此之外,这种优化方案还可以显著降低堆内内存中FST的占用比例,提高堆内内存利用率,并降低GC开销。同时,使用off-heap内存还可以降低GC的次数和持续时间,从而提高整个系统的性能和稳定性。
因此,需要在使用FST索引时考虑内存占用的问题,并将FST移至堆外并按需加载FST,以提高系统的性能和稳定性。这个优化方案可以显著提高堆内内存利用率,降低GC开销,并提升单个节点管理磁盘的能力。
原生版本实现 off-heap 的方式,将 FST 对象放到 MMAP 中管理。虽然这种方式实现简单,但是有可能会被系统回收掉,导致读盘操作,进而带来性能的损耗。HBase 2.0 版本中 off-heap 的实现方式,则是在堆外建立了 cache,但是索引仍然在堆内,而且淘汰策略完全依赖于 LRU 策略,冷数据不能及时清理。而在堆外建立 cache,可以保证 FST 的空间不受系统影响,实现更精准的淘汰策略,提高内存使用率,同时采用多级 cache 的管理模式来提升性能。虽然这种方式实现起来比较复杂,但是收益很明显。因此,读者可以根据实际需求选择合适的 off-heap 方案。
为了优化访问FST的效率,可以考虑采用一种综合方案,即LRUcache+零拷贝+两级cache。在这种方案中,LRUcache被建立在堆外,并且当堆内需要访问FST时,会从磁盘加载到LRUcache中。由于Lucene默认的访问FST的方式使用一个堆内的buffer,直接从堆外拷贝到堆内的buffer会占用大量的时间和资源。因此,对Lucene访问FST的方式进行了改造,将buffer不直接存放FST,而是存放指向堆外对象的指针,这样就实现了堆内和堆外之间的零拷贝。
需要注意的是,这里的零拷贝和操作系统中的用户态和内核态的零拷贝是两个不同的概念。但是,根据key去查找堆外对象的过程也会损耗一部分性能,例如计算hash、数据校验等。为了进一步优化性能,可以利用Java的弱引用建立第二层轻量级缓存。弱引用指向堆外的地址,只要有请求使用,这个key就不会被回收,可以重复利用而无需重新获取。一旦不再使用,这个key就会被GC回收掉,并回收掉堆外对象指针。但是,堆外对象指针回收之后需要清理堆外内存,不能浪费一部分内存空间。为了解决这个问题,最好的办法是在堆内对象地址回收的时候直接回收堆外对象。然而,Java没有析构的概念,可以利用弱引用的ReferenceQueue,在对象要被GC回收时将对象指向的堆外内存清理掉,这样就可以完美解决堆外内存析构的问题,并提高内存利用率。
为了帮助读者更好地理解上述内容,本节将采用以下故事的形式对上述内容进行再次解析:
曾经有一个叫做小明的程序员,他在开发一个搜索引擎的项目中遇到了一个问题:搜索引擎的堆内存占用率过高,而磁盘的使用率却相对较低。他研究了一番后发现,这是由于搜索引擎中使用的倒排索引(FST)需要很大的内存比例才能常驻内存,而在查询时需要解压缩FST,占用了大量的堆内存空间。
小明急于解决这个问题,因此他开始了一段冒险之旅。他发现,将FST移至堆外并按需加载FST可以显著提高堆内内存利用率并降低垃圾回收开销,从而提高单个节点对磁盘的管理能力。他做了一些实验后,发现将FST放到MMAP中管理的方式实现简单,但是有可能会被系统回收掉,导致读盘操作,进而带来性能的损耗。而在堆外建立cache,可以保证FST的空间不受系统影响,实现更精准的淘汰策略,提高内存使用率,同时采用多级cache的管理模式来提升性能。
在这个过程中,小明还研究了一种综合方案:LRUcache+零拷贝+两级cache。他把LRUcache建立在堆外,并且当堆内需要访问FST时,会从磁盘加载到LRUcache中。同时,他利用Java的弱引用建立第二层轻量级缓存,指向堆外的地址,只要有请求使用,这个key就不会被回收,可以重复利用而无需重新获取。一旦不再使用,这个key就会被GC回收掉,并回收掉堆外对象指针。最后,小明成功地实现了从堆内移动FST到堆外的优化方案,并大幅提高了系统的性能和稳定性。他心满意足地把这个方案分享给了同事们,为搜索引擎的开发作出了重大贡献。
🍊 扩展性优化
Elasticsearch中的元数据管理模型是由Master节点来管理元数据,并同步给其他节点。以建索引流程为例,首先Master节点会分配分片,并产生差异的元数据,这些元数据会发送到其他节点上。当大多数Master节点返回元数据后,Master节点会发送元数据应用请求,其他节点开始应用元数据,并根据新旧元数据推导出各自节点的分片创建任务。在这个过程中有一些瓶颈点,主要有以下几点:
首先,Mater节点在分配分片时需要进行正反向转换。由于路由信息是由分片到节点的映射,而在做分片分配时需要节点到分片的映射,因此需要知道每个节点上的分片分布。因此,分片分配完毕后还需要将节点到分片的映射转换回来。这个转换过程涉及多次的全量遍历,这在大规模分片的情况下会存在性能瓶颈。其次,在每次索引创建的过程中,会涉及多次元数据同步。在大规模的节点数场景下,会出现同步瓶颈,由于节点数量过多,可能会出现某些节点一些网络抖动或Old GC等问题,导致同步失败。为了解决以上问题,可以从三个方面进行优化:首先,采用任务下发的方式,定向下发分片创建任务,避免了多次全节点元数据同步,从而优化分片创建导致的元数据同步瓶颈。其次,针对分配分片过程中多次正反向遍历的问题,采用增量化的数据结构维护的方式,避免了全量的遍历,从而优化分配分片的性能问题。最后,为了优化统计接口的性能,采用缓存策略避免多次重复的统计计算,大幅降低资源开销。
为了帮助读者更好地理解上述内容,本节将采用以下故事的形式对上述内容进行再次解析:
在一个遥远的星系中,有一个由许多机器人组成的世界。这些机器人可以相互通信,进行各种任务。其中,有一个机器人被选为主节点,负责管理所有机器人的信息。这个主节点的职责很重要,因为它需要协调所有机器人的工作,尤其是在建索引流程中。每当有任务需要建立索引时,主节点会分配分片,并产生差异的元数据,这些元数据会发送到其他机器人上。但是,这个过程并不总是顺利的。主节点需要在分配分片时进行正反向转换,这样每个机器人才能知道自己需要建立哪些索引。这个转换过程需要多次的全量遍历,如果分片数量很多,这个过程会非常耗时。另外,每次索引创建的过程中,也会涉及多次元数据同步。而在大规模的机器人场景下,可能会出现同步瓶颈,导致同步失败。
为了解决这些问题,机器人们开始探索各种优化方案。首先尝试了任务下发的方式,定向下发分片创建任务,避免了多次全节点元数据同步,从而优化了分片创建导致的元数据同步瓶颈。接着,采用增量化的数据结构维护的方式,避免了全量的遍历,从而优化了分配分片的性能问题。最后,采用了缓存策略来优化统计接口的性能,避免多次重复的统计计算,大幅降低了资源开销。这些优化措施让机器人们的工作更加顺利,他们可以更快地完成任务,并且不再遇到瓶颈问题。同时,这些措施也为未来的工作提供了一个优化的思路,让机器人们可以不断地改进和进步。
🍊 分析性能问题
分析性能问题的路径可以遵循以下步骤:首先,明确性能问题后,进行流量录制,以获取一个用于后续基准压测的测试集合。随后,使用相关的性能分析工具,首先明确是否存在CPU热点或IO问题。对于Java技术栈,可以使用Scaple、JProfiler、Java Flight Recorder、Async Profiler、Arthas、perf等工具进行性能分析。
利用火焰图进行分析,配合源代码进行数据分析和验证。此外,在Elasticsearch中,也可以使用Kibana的Search Profiler协助定位问题。接下来,进行录制大量的流量,抽样分析后,以场景为例,通过Profiler分析,发现TermInSetQuery占用了一半以上的耗时。明确问题后,从索引和检索链路两侧进行分析,评估问题并设计多种解决方案,并利用Java Microbenchmark Harness(JMH)代码基准测试工具验证解决方案的有效性。最后,集成验证解决方案的最终效果。
🌟 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.项目经验
项目的业务背景、解决的问题、实现的效果和带来的价值(提高了公司效率、降低成本、增加收益)、在项目中的角色和能力、项目的整体架构和技术栈(使用的框架、数据库、服务器、使用什么设计模式、优化技巧、性能调优)、自己在项目中的角色和贡献(参与的模块、负责的功能、解决的问题、在项目中的领导能力、带领团队完成项目、项目管理经验)、给出真实的数据和指标(使用的数据量、用户量、处理速度、项目的规模)、项目的演示链接和作品代码演示链接。