建站工具箱 discuz,网站负责人幕布照片,android开发 wordpress,网站优化策略文章目录 1.具备扎实的Java基础集合HashMap底层工作原理HashMap版本问题HashMap并发修改异常HashMap影响HashMap性能的因素HashMap使用优化 SynchronizedThreadLocalAQS线程池JVM内存模型类加载机制与双亲委派垃圾回收算法、垃圾回收器、空间分配担保策略引用计数器算法、可达性… 文章目录 1.具备扎实的Java基础集合HashMap底层工作原理HashMap版本问题HashMap并发修改异常HashMap影响HashMap性能的因素HashMap使用优化 SynchronizedThreadLocalAQS线程池JVM内存模型类加载机制与双亲委派垃圾回收算法、垃圾回收器、空间分配担保策略引用计数器算法、可达性分析、强软弱虚引用、GC的过程、三色标记、跨代引用引用计数器算法、可达性分析强软弱虚引用GC的过程三色标记跨代引用 内存泄漏与堆积、溢出JVM调优经验 2.深入理解MySQL关系型数据库索引数据结构隔离级别、脏读、 不可重复读、幻读、幻影行行锁、表锁、间隙锁、死锁行锁的表现表锁的表现间隙锁的表现死锁 原子性底层实现原理undo log日志 一致性底层实现原理1. 事务机制2. 锁机制3. 隔离级别4. MVCC 持久性底层实现原理redo log机制隔离性底层实现原理MVCC多版本并发控制BufferPool缓存机制主键自增长实现原理1. 定义自增长主键2. 实现自增长3. 锁机制4. 如何处理插入失败 索引失效、聚集索引、辅助索引、覆盖索引、联合索引0.索引失效的几种情况1. 什么是聚集索引2. 什么是辅助索引3. 什么是覆盖索引4. 如何创建一个联合索引5. 聚集索引与辅助索引的优缺点6. 什么情况下应该使用覆盖索引 SQL的执行流程MySQL调优表结构设计建索引SQL优化SQL编写SQL优化工具慢SQL优化 数据分区表分区分区表水平分区 灾备处理冷备份热备份冷备份与热备份的权衡备份注意事项 高可用MMMMHAMGR 异常发现处理数据库监控数据库日志数据库巡检资源评估 数据服务子表结构生成数据迁移数据校验 读写分离主从数据同步中间件路由缓存路由 3.深入理解Redis缓存多路复用模式单线程和多线程模型Redis五大数据类型的应用场景简单字符串、链表、字典、跳跃表、压缩列表Redis持久化RDB持久化AOF持久化混合持久化持久化底层实现原理RDB持久化底层实现原理AOF持久化底层实现原理混合持久化底层实现原理save与bgsave Redis过期策略惰性删除流程定期删除流程内存淘汰机制RDB对过期key的处理AOF对过期key的处理 Redis与数据库的数据一致性双删策略延时双删策略异步延时删除策略 Redis分布式锁底层实现如何实现使用redis锁会有很多异常情况如何处理这些异常呢1.redis服务挂掉了抛出异常了锁不会被释放掉新的请求无法进来出现死锁问题2.服务器果宕机了导致锁不能被释放的现象3.锁的过期时间比业务执行时间短会存在多个线程拥有同一把锁的现象4.锁的过期时间比业务执行时间短锁永久失效 Redis热点数据缓存互斥锁mutex永远不过期 高并发高可用哨兵机制Redis 哨兵主备切换的数据丢失问题异步复制导致的数据丢失 集群模式集群协议集中式gossip 协议 多级缓存架构并发竞争Redis cluster 的高可用与主备切换原理主从架构下的数据同步主从复制/数据同步主从架构下的数据部分复制断点续传数据丢失发生的场景以及解决方案主从/哨兵/集群区别主从架构哨兵集群 高可用/哨兵集群/主备切换 Redis调优绑定CPU内核使用复杂度过高的命令大key的存储和删除数据集中过期内存淘汰策略碎片整理内存大页数据持久化与AOF刷盘丢包/中断/CPU亲和性操作系统Swap与主从同步监控高可用缓存雪崩、穿透、击穿、热点缓存重构、缓存失效 4.深入理解消息中间件三种mq对比消息丢失消息重复消费消息顺序消息积压延迟队列消息队列高可用 5.深入理解开源框架Spring Bean的生命周期Spring Bean线程安全Spring Bean 的实现原理Spring Bean 的线程安全性问题解决方案1. 使用线程安全 Bean2. 对 Bean 进行同步3. 将 Bean 的作用域范围设为 prototype4. 使用 AOP 实现线程安全性 举例说明 单例模式的单例BeanSpring AOP底层实现原理Spring循环依赖Spring容器启动流程Spring事务及传播机制底层原理Spring IOC容器加载过程与依赖注入Spring的自动装配Spring6.0核心新特性Spring Boot自动装配Spring Boot启动过程Spring Framework的SPI机制SpringMVC执行流程Dubbo服务发现与调用Dubbo容错机制Dubbo负载均衡Dubbo序列化协议动态感知服务下线ZooKeeper选举脑裂与假死Zab协议Quorum机制ACL访问控制列表Configuration、Autowired、Resource、ComponentScan、Conditional、Lazy、Primary、Import、SpringBootApplication注解的底层实现 6.深入理解ElasticSearch7.熟练使用设计模式8.抢购系统落地9.工作经验10.项目经验 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则是一种比较推荐的解决方案。它使用了volatileCAS等技术来减少锁竞争对性能的影响避免了对全局加锁。在JDK1.7版本中ConcurrentHashMap使用了分段锁技术将数据分成一段一段的存储并为每个段配备了锁。这样当一个线程占用锁访问某一段数据时其他段的数据也可以被其他线程访问从而能够实现真正的并发访问。在JDK1.8版本中ConcurrentHashMap内部使用了volatile来保证并发的可见性并采用CAS来确保原子性来解决了性能问题和数据一致性问题。
HashMap影响HashMap性能的因素
影响HashMap性能的两个关键因素加载因子和初始容量。加载因子用于确定HashMapK,V中存储的数据量并且默认加载因子为0.75。如果加载因子比较大扩容发生的频率就会比较低而浪费的空间会比较小但是发生hash冲突的几率会比较大。举个例子如果加载因子为1HashMap长度为128实际存储元素的数量在64至128之间这个时间段发生hash冲突比较多会影响性能。如果加载因子比较小扩容发生的频率会比较高浪费的空间也会比较多但是发生hash冲突的几率会比较小。比如如果加载因子为0.5HashMap长度为128当数量达到65的时候会触发扩容扩容后为原理的256256里面只存储了65个浪费了。因此我们可以取一个平均数0.75作为加载因子。另一个影响HashMap性能的关键因素是初始容量它始终为2的n次方可以是16、32、64等这样的数字。即使你传递的值是13数组长度也会变成16因为它会选择最近的2的n次方的数。在HashMap中使用hash值 长度-1的二进制进行运算来得到元素在数组中的下标。这样做可以保证运算得到的值可以落到数组的每一个下标上避免了某些下标永远没有元素的情况。
举个例子如果我有一个HashMap容量为16我的hash值是 11001110 11001111 00010011 11110001hash值 然后我要进行运算运算的值是 00000000 00000000 00000000 0000111116-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(预期大小)来创建一个HashMapGuava会帮我们完成计算过程同时考虑设定初始加载因子。最后如果Map是长期存在而key又是无法预估的那就可以适当加大初始大小同时减少加载因子降低冲突的机率。在长期存在的Map中降低冲突概率和减少比较的次数更加重要。
Synchronized
Synchronized关键字在Java语言中是用来保证同一时刻只有一个线程执行被Synchronized修饰的代码块或方法。如果Synchronized修饰的是方法或对象则该对象锁是非静态的如果修饰的是静态方法或类则该类锁是静态的所有的该类对象共用一个锁。每个Java对象都有一把看不见的锁也称为内部锁或Monitor锁。Synchronized的实现方式是基于进入和退出Monitor对象来实现方法和代码块同步。每个Java对象都是天生的MonitorMonitor监视器对象存在于每个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时就会升级为重量级锁。这时锁标志被置为10MarkWord中的指针指向重量级的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()释放锁对一个锁加多次从而实现可重入锁。当state1时代表当前对象锁已经被占用其他线程来加锁时则会失败。再看加锁线程的变量里面是否为自己。如果不是就说明有其他线程占用了这个锁失败的线程被放入一个等待队列中并等待唤醒的时候经常会使用自旋的方式不停地尝试获取锁等待已经获得锁的线程释放锁才能被唤醒。当它释放锁的时候将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存活区比例为811。进行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之前检查一下老年代最大可用连续空间假设老年代最大可用连续空间是2GBJVM就会拍拍老年代的肩膀说放心哪怕年轻代里面这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类来实现软引用。
SoftReferenceObject softRef new SoftReference(new Object());
// softRef是一个软引用弱引用与软引用类似它也是用于描述一些还有用但并非必需的对象但是与软引用不同弱引用被回收的时机更加快速我们可以使用弱引用来实现一些临时性的对象比如缓存中的某些对象当不再需要这些对象时JVM会自动回收它们。在垃圾回收时只要发现存在弱引用引用的对象就会被回收。可以通过WeakReference类来实现弱引用。
WeakReferenceObject weakRef new WeakReference(new Object());
// weakRef是一个弱引用虚引用是最为特殊的引用类型它与前面的三种引用类型不同虚引用并不会影响对象的生命期而是用于在对象被回收时收到一个系统通知可以实现资源的释放比如文件句柄、网络连接等如果我们直接使用强引用进行管理容易出现资源泄露的问题。而使用虚引用则可以避免这个问题因为虚引用在对象被回收时会收到一个通知然后程序可以在收到通知之后及时地释放资源。这样程序员可以在对象被回收时进行一些清理操作。虚引用必须与ReferenceQueue虚引用队列一起使用。
PhantomReferenceObject 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 GCFull 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了。 由于还没有提交事务所以b2这行数据还是被update持有锁对于其他事务是不可见的避免了脏读。 打开窗口2查询select * from test_innodb_lock where b 2;发现a的值还是没变。更新数据update test_innodb_lock set a 2 where b 2;发现一直阻塞没有继续往下执行。 由于第一个会话持有了这一行的锁第二个窗口的会话就对这一行进行修改会阻塞。 步骤2 窗口1提交事务 窗口2查询select * from test_innodb_lock where b 2;发现a的值改为1了。更新数据update test_innodb_lock set a 2 where b 2;发现可以更新成功。
表锁的表现
将mysql数据库改为手动提交 步骤1 窗口1更新数据update test_innodb_lock set b 0 where a 1 or a 2 窗口2更新数据update test_innodb_lock set b 3 where a 3发现阻塞没有继续往下执行 由于还没有提交事务并且使用了or导致索引失效行级锁升级为表锁窗口1只要没有提交事务那么窗口2任何对test_innodb_lock表的操作都会阻塞直到窗口1提交事务窗口2才可以继续执行下去。 间隙锁的表现
假设有一张表test_innodb_lock表有a和b二个字段a字段里面的数据缺了2,4,6,8这些就是间隙这个间隙引发的锁就叫做间隙锁一般发生在范围查询里面。 将mysql数据库改为手动提交 步骤1 窗口1更新数据update test_innodb_lock set b 5 where a 1 and a 9 窗口2更新数据insert into test_innodb_lock values(4,4)发现阻塞了没有继续往下执行。 窗口1进行了一个范围查询会把a 1 and a 9加上锁窗口2这个会话想插入2,4,6,8是无法插入的因为它已经被窗口1的会话持有了锁。 间隙锁Gap Lock是MySQL中的一种特殊锁机制用于保证事务的隔离性。
假设有这样一张表
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 |
|-------------------------------------------------|
|id1 (Gap Lock) | |id2 |事务B现在要查询id为2的记录但由于id为2的间隙被事务A锁定所以事务B需要等待事务A提交或回滚才能进行查询如下图
| TX A | | TX B |
|-------------------------------------------------|
|id1 (Gap Lock) | | |
| | |waiting for id2|如果在事务A提交或回滚之前有其他事务想要在id为2的间隙中插入数据那么该事务会被阻塞直到间隙锁被释放。
间隙锁的存在可以防止幻读现象的发生。例如如果去掉间隙锁那么在事务A执行插入数据之前如果事务B插入了一条id为2的记录那么事务A在执行插入时会发现id2已经存在从而引发幻读。
因此间隙锁是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申请事务idMySQL内部是严格按照事务的启动顺序来分配事务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方式这是性能最高的方式但是如果是并发插入在每次插入的时候自增长的值就不是连续的而是根据锁的竞争情况产生的。这就会导致主从复制的方式SBRstatement-based replication出现问题因为主从之间的自增长值不一致会导致数据不一致的情况。因此如果使用SBR进行主从复制不建议将innodb_autoinc_lock_mode的值设置为2。而使用row-based replicationRBR可以确保在主库上使用互斥量产生自增长值并在从库上使用相同的方法生成相同的自增长值。这样就可以在保证并发性能的同时保持主从复制之间数据的一致性。
使用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 设置成2query_cache_type有3个值0代表关闭查询缓存1代表开启2代表当sql语句中有SQL_CACHE关键词时才缓存。确定要使用查询缓存的语句用 SQL_CACHE显式指定比如select SQL_CACHE * from user where ID5
第三步如果没有命中查询缓存就要开始真正执行语句了。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.nameliaozhiwei and user2.namehaoshuai;既可以先从表user1 里面取出 nameliaozhiwei的 ID 值再根据 ID 值关联到表user2再判断user2 里面 name的值是否等于liaozhiwei。也可以先从表user2 里面取出 namehaoshuai的 ID 值再根据 ID 值关联到user1再判断user1 里面 name 的值是否等于haoshuai。这两种执行方法的逻辑结果是一样的但是执行的效率会有不同而优化器的作用就是决定选择使用哪一个方案。执行方案就确定下来了然后进入执行器阶段。
第五步开始执行的时候要先判断一下你对这个表有没有执行查询的权限如果没有就会返回没有权限的错误。如果有权限就打开表继续执行。打开表的时候执行器就会根据表的引擎定义去使用这个引擎提供的接口。比如我有一条sql:select * from user where id10;执行器调用 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条件的SQLWHERE条件字段未创建索引会进行全表扫描。COUNT()只统计总行数聚簇索引的叶子节点存储整行记录非聚簇索引的叶子节点存储行记录主键值。非聚簇索引比聚簇索引小选择最小的非聚簇索引扫表更高效。 (4)当数据量较大时查询只返回必要的列和行LIMIT 分页限制返回的数据减少请求的数据量插入建议分批次批量插入以提高性能。 (5)对于大连接的查询SQL由于数据量较多、又是多表容易出现整个事务日志较大消耗大量资源从而导致一些小查询阻塞所以优化方向是将它拆分成单表查询在应用程序中关联结果这样更利于高性能可伸缩同时由于是单表减少了锁竞争效率上也有一定提升。 (6)尽量明确只查询所需列避免使用SELECT *。SELECT *会导致全表扫描降低性能。若必须使用SELECT 可以考虑使用MySQL 5.6及以上版本因为这些版本提供了离散读优化Discretized Read Optimization将离散度高的列放在联合索引的前面以提高性能。 索引下推ICPIndex Condition Pushdown优化ICP优化将部分WHERE条件的过滤操作下推到存储引擎层减少上层SQL层对记录的索取从而提高性能。在某些查询场景下ICP优化可以大大减少上层SQL层与存储引擎的交互提高查询速度。 多范围读取MRRMulti-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集群。它使用虚拟IPVIP机制实现集群的高可用。集群中主节点通过一个虚拟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;}ListString 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 %;//获取子表名ListString tables configExtMapper.getTableInfoList(table);if(CollectionUtils.isEmpty(tables)){return false;}//获取主表结构列信息ListColumnInfo 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;}//获取子表结构列信息ListColumnInfo 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)、datetimecolumn.setComment(masterColumn.getComment());//字段备注如备注column.setAlterName(masterColumn.getAlterName());//修改的列名//创建子表字段configExtMapper.alterTableColumn(column);}}return true;
}主表索引同步子表代码如下 主表索引同步子表
/**
* 主表索引同步子表
* param masterTableName 主表名
* return
*/
private Boolean syncAlterConfigIndex(String masterTableName) {String table masterTableName %;//获取子表名ListString tableInfoList configExtMapper.getTableInfoList(table);if (tableInfoList.isEmpty()){return false;}// 获取所有索引ListString allIndexFromTableName configExtMapper.getAllIndexNameFromTableName(masterTableName);if (CollectionUtils.isEmpty(allIndexFromTableName)) {return false;}for (String indexName : allIndexFromTableName) {//获取拥有索引的列名ListString indexFromIndexName configExtMapper.getAllIndexFromTableName(masterTableName, indexName);for (String tableName : tableInfoList) {if (!tableName.startsWith(masterTableName)) {continue;}//获取索引名称ListString addIndex configExtMapper.findIndexFromTableName(tableName, indexName);if (CollectionUtils.isEmpty(addIndex)) {//创建子表索引configExtMapper.commonCreatIndex(tableName, indexName, indexFromIndexName);}}}return true;
}上述代码的SQL代码如下 子表结构生成的SQL
!--校验子表是否存在 这里db_user写死了数据库名称后面可以根据实际情况调整--
select idcheckTable resultTypejava.lang.Integer SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA db_user AND TABLE_NAME #{tableName};
/select
!--创建子表结构--
update idcreateConfigTable CREATE TABLE ${newTableName} LIKE ${sourceName};
/update
!--获取子表名--
select idgetTableInfoList resultTypejava.lang.StringSELECT TABLE_NAMEFROM INFORMATION_SCHEMA.TABLESWHERE TABLE_NAME LIKE #{tableName};
/select
!--获取主/子表结构列信息 这里db_user写死了数据库名称后面可以根据实际情况调整--
select idgetColumnInfoList resultTypecom.yunxi.datascript.config.ColumnInfoSELECT COLUMN_NAME AS columnName,COLUMN_DEFAULT AS columnDef -- 是否默认值,IS_NULLABLE AS isNull -- 是否允许为空,COLUMN_TYPE AS columnType -- 字段类型,COLUMN_COMMENT AS comment -- 字段备注FROM INFORMATION_SCHEMA.COLUMNSWHERE TABLE_SCHEMA db_userAND TABLE_NAME #{tableName}ORDER BY ORDINAL_POSITION ASC;
/select
!--创建子表字段--
update idalterTableColumn parameterTypecom.yunxi.datascript.config.ColumnInfoALTER TABLE ${tableName}choosewhen testaddColumnADD COLUMN/when otherwiseMODIFY COLUMN/otherwise/choose${columnName}${columnType}choosewhen testisNull ! null and isNull NONOT NULL/when otherwiseNULL/otherwise/chooseif testcolumnDef ! null and columnDef ! DEFAULT #{columnDef}/ifif testcomment ! null and comment ! COMMENT #{comment}/ifif testalterName ! null and alterName ! AFTER ${alterName}/if
/update
!--获取所有索引--
select idgetAllIndexNameFromTableName resultTypejava.lang.StringSELECT DISTINCT index_name FROM information_schema.statistics WHERE table_name #{tableName} AND index_name ! PRIMARY
/select
!--获取拥有索引的列名--
select idgetAllIndexFromTableName resultTypejava.lang.StringSELECT COLUMN_NAME FROM information_schema.statistics WHERE table_name #{tableName} AND index_name #{idxName} AND index_name ! PRIMARY
/select
!--获取索引名称--
select idfindIndexFromTableName resultTypejava.lang.StringSELECT index_name FROM information_schema.statistics WHERE table_name #{tableName} AND index_name #{idxName}
/select
!--创建子表索引--
update idcommonCreatIndexCREATE INDEX ${idxName} ON ${tableName}foreach collectionlist itemitem 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这种类型是简单的字符串列表按照插入顺序排序可以添加一个元素到列表的头部或者尾部它的底层实际上是个链表结构。这种类型更多的是用在文章发布上面类似微博消息和微信公众号文章在我之前的项目里面也有用到比如说我关注了二个媒体这二个媒体先后发了新闻我就可以看到先发新闻那家媒体的文章它可以通过lpushrpop队列这种数据结构实现先进先出当然也可以通过lpushlpop实现栈这种数据结构来到达先进后出的功能。 然后就是集合set底层是字典实现的查找元素特别快另外set 数据类型不允许重复利用这两个特性我们可以进行全局去重比如在用户注册模块判断用户名是否注册。可以通过sadd、smembers等命令实现微信抽奖小程序微信微博点赞收藏标签功能。还可以利用交集、并集、差集的特性实现微博微信的关注模型交集和并集很好理解差集可以解释一下就是用第一个集合减去其他集合的并集剩下的元素就是差集。举个微博关注模型的例子我关注了张三和李四张三关注了李四和王五李四关注了我和王五。 我进入了张三的主页 查看共同关注的人李四取出我关注的人和张三关注的人二个集合取交集得出结果是李四就是通过SINTER交集实现的。 查看我可能认识的人王五取出我关注的人和张三关注的人二个集合取并集得出结果是张三李四王五拿我关注的人张三李四减去并集里的元素剩下的王五就是我可能认识的人可以通过并集和差集实现。 查看我关注的人也关注了他王五取出我关注的人他们关注的人李四王五我王五的交集就是王五。 最后就是有序集合zset有序的集合可以做范围查找比如说排行榜展示当日排行前十。
简单字符串、链表、字典、跳跃表、压缩列表
简单字符串的底层编码分为三种intraw或者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持久化有三种方式RDBAOFRDB和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重写AOFAOF重写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是基于梯形数组每个数组上面就挂了一个CounterCounter是用来统计它的服务次数的通过访问次数来进行升级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如果setnx和ex结果为true说明此时没有其他线程重建缓存 那么当前线程执行缓存构建逻辑 2.2如果setnx和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 的复制是异步的所以可能有部分数据还没复制到 slavemaster 就宕机了此时这部分数据就丢失了。 脑裂导致的数据丢失某个 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 的高可用与主备切换原理 如果一个节点认为另外一个节点宕机这是属于主观宕机。如果多个节点都认为另外一个节点宕机了那么就是客观宕机跟哨兵的原理几乎一样sdownodown。流程为如果一个节点认为某个节点pfail 了那么会在 gossip ping 消息中 ping 给其他节点如果超过半数的节点都认为 pfail 了那么就会变成fail 。 每个从节点都根据自己对 master 复制数据的 offset来设置一个选举时间offset越大复制数据越多的从节点选举时间越靠前优先进行选举。所有的 master node 开始 slave 选举投票给要进行选举的slave 进行投票如果大部分 master node N/2 1 都投票给了某个从节点那么选举通过那个从节点可以切换成master。从节点执行主备切换从节点切换为主节点。 主从架构下的数据同步
主从复制/数据同步
master会启动一个后台线程开始生成一份RDB快照文件同时还会将从客户端收到的所有写命令缓存在内存中。RDB文件生成完毕之后master会将这个RDB发送给slaveslave会先写入本地磁盘然后再从本地磁盘加载到内存中。然后master会将内存中缓存的写命令发送给slaveslave也会同步这些数据。
主从架构下的数据部分复制断点续传
当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_cpulistRedisServer和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_memory0表示内存不足时不会分配导致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服务的高可用还有多级缓存、冷热分离可以保证高可用。 商品详情页在电商平台的秒杀场景中涉及商品信息的动态展示和高并发访问需要通过一系列手段保证系统的高并发和高可用通过采用NginxLua架构、CDN缓存、本地应用缓存和分布式缓存等多种技术手段实现了商品详情页的动态化和缓存优化提高用户访问商品详情页的速度和体验。同时通过开关前置化和缓存过期机制确保了缓存数据的有效性降低了对后端数据库的访问压力。 7.12.1主从同步和哨兵机制 主从复制通常采用异步方式可能导致主节点数据尚未完全复制至从节点主节点便已故障导致数据丢失。因此需要控制复制数据的时长和ACK延迟降低数据丢失风险。 主从切换过程通常使用哨兵机制。但在主节点正常运行时可能因与某从节点连接中断哨兵误判主节点已故障。在此情况下哨兵可能启动选举将某从节点升级为主节点导致集群出现两个主节点发生脑裂。旧主节点恢复网络后将被升级为从节点并挂载至新主节点导致自身数据丢失并需从新主节点复制数据。而新主节点并未包含后续客户端写入的数据导致这些数据丢失。为降低数据丢失风险可设置连接主节点最少的从节点数量和从节点连接主节点最大的延迟时间若主节点与从节点断开连接且从节点超过阈值时间未收到ACK消息则拒绝客户端的写请求将数据丢失控制在可控范围。 7.12.2多级缓存 Java多级缓存是一种常见的优化策略可以有效地提高系统的性能和响应速度。 1.浏览器缓存 在页面间跳转时从本地缓存获取数据或在打开新页面时根据Last-Modified头来CDN验证数据是否过期减少数据传输量。 CDN缓存当用户点击商品图片或链接时从最近的CDN节点获取数据而非回源到北京机房提升访问性能。 2.服务端应用本地缓存 采用NginxLua架构通过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.NginxLua架构 (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对比
使用消息队列有解耦扩展性削峰异步等功能市面上主流的几款mqrabbitmqrocketmqkafka有各自的应用场景。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以后的版本支持DledgeDLedger是基于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不会等之前那条消息完全发送成功才去发送下一条消息这样可能会出现发送了123条消息但是第1条超时了后面两条发送成功再重试发送第1条消息这时消息在broker端的顺序就是231了。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_1stopic_5stopic_10stopic_2h然后通过定时器进行轮训消费这些topic查看消息是否到期如果到期就把这个消息发送到具体业务处理的topic中队列中消息越靠前的到期时间越早具体来说就是定时器在一次消费过程中对消息的发送时间做判断看下是否延迟到对应时间了如果到了就转发如果还没到这一次定时任务就可以提前结束了。 mq设置过期时间就会有消息失效的情况如果消息在队列里积压超过指定的过期时间就会被mq给清理掉这个时候数据就没了。解决方案也有手动写程序将丢失的那批数据一点点地查出来然后重新插入到 mq 里面去。
消息队列高可用
对于RocketMQ来说可以使用Dledger主从架构来保证消息队列的高可用这个在上面也有提到过。然后在说说rabbitmq它提供了一种叫镜像集群模式在镜像集群模式下你创建的 queue无论元数据还是 queue 里的消息都会存在于多个实例上就是说每个 RabbitMQ 节点都有这个 queue 的一个完整镜像包含 queue 的全部数据的意思。然后每次你写消息到 queue 的时候都会自动把消息同步到多个实例的 queue 上。RabbitMQ 有很好的管理控制台可以在后台新增一个策略这个策略是镜像集群模式的策略指定的时候是可以要求数据同步到所有节点的也可以要求同步到指定数量的节点再次创建 queue 的时候应用这个策略就会自动将数据同步到其他的节点上去了。只不过消息需要同步到所有机器上导致网络带宽压力和消耗很重。最后再说说kafka它是天然的分布式消息队列在Kafka 0.8 以后提供了副本机制一个 topic要求指定partition数量每个 partition的数据都会同步到其它机器上形成自己的多个 replica 副本所有 replica 会选举一个 leader 出来其他 replica 就是 follower。写的时候leader 会负责把数据同步到所有 follower 上去。如果某个 broker 宕机了没事儿那个 broker上面的 partition 在其他机器上都有副本的如果这上面有某个 partition 的 leader那么此时会从 follower 中重新选举一个新的 leader 出来。
5.深入理解开源框架 熟悉Spring中Bean的生命周期与线程安全、单例模式的单例Bean、Spring AOP底层实现原理、Spring循环依赖、Spring容器启动流程、Spring事务及传播机制底层原理、Spring IOC容器加载过程与依赖注入、Spring的自动装配、Spring6.0核心新特性Spring Boot自动装配、Spring Boot启动过程、Spring Framework的SPI机制SpringMVC执行流程Dubbo服务发现与调用、Dubbo容错机制、Dubbo负载均衡、Dubbo序列化协议、动态感知服务下线ZooKeeper选举、脑裂与假死、Zab协议、Quorum机制、ACL访问控制列表。深入理解Configuration、Autowired、Resource、ComponentScan、Conditional、Lazy、Primary、Import、SpringBootApplication等注解的底层实现。 Spring Bean的生命周期
Spring Bean的生命周期是指在应用程序运行过程中从创建到销毁的整个过程包括Bean的实例化、依赖注入、初始化、销毁等多个阶段。在这个过程中Spring框架会调用多个方法和事件来完成Bean的创建和处理这个过程非常重要对于了解Spring框架的工作原理和优化Bean的性能非常有帮助。下面我们来详细介绍Spring Bean的生命周期。
实例化 - Instantiation
在Spring容器中当我们在配置文件中声明一个Bean的时候Spring会通过反射机制实例化一个Bean对象这个对象被称为原型对象。实例化的过程中Spring通过构造函数或者工厂方法来创建Bean实例。构造函数注入和工厂方法注入都是在这一步完成的。
属性设置 - Populate properties
在实例化完成后Spring会自动为Bean注入属性。这个过程称为属性设置。Spring使用反射机制或者Setter方法来注入属性。我们可以通过在Bean定义xml中配置标签来设置属性值。
初始化前 - Initialization callback
初始化前阶段是为Bean做一些初始化的准备工作。在这个阶段Spring会检查是否有实现了InitializingBean接口的Bean。如果实现了就会在这个时候调用Bean中的afterPropertiesSet方法。还有一种方式可以在xml文件中声明init-method属性来指定Bean的初始化方法当Bean被实例化之后会自动调用。
初始化后 - Post-initialization callback
初始化后阶段是Bean的最后一次变更一旦进入此阶段Bean就已经完成了初始化。在这个阶段Spring会检查是否有实现了BeanPostProcessor接口的类。如果有就会在这个时候调用postProcessBeforeInitialization方法来进行额外的初始化操作。这可以用来拦截Bean的初始化对Bean进行修改或封装。
初始化完成 - Initialization finished
初始化完成阶段是Bean的最后一个阶段这个时候Bean已经被完全初始化了。这个时候Bean就已经可以被容器使用了。
Bean销毁 - Destruction
当容器需要销毁一个Bean时会先检查Bean是否实现了DisposableBean接口。如果实现了就会调用Bean中的destroy方法。还有一种方式可以在xml文件中声明destroy-method属性来指定Bean的销毁方法当Bean被销毁之前会自动调用。
举个例子假设你是一位追求完美的花艺师。你在春天的花展上带来了你独特的艺术作品。这次你决定采用Spring Bean来打造花展中的一个展位。
首先你需要在花展中找到一个空间。这个空间就相当于Spring容器中的配置文件。你需要在配置文件中声明Bean并为它们设置属性和依赖关系这些都是在属性设置阶段完成的。在这个过程中你会逐步构建出一个原型对象。
接下来你需要把所有花束放在花架上。这就相当于初始化前阶段你会在这个阶段进行一些准备工作比如检查花束的数量、清洁花束、调整花束的位置等。在这个阶段你也可以使用实现了InitializingBean接口的类来执行一些自定义的初始化操作。
初始化后阶段就像你在花展前调整花束的最后一次机会。你可以使用实现了BeanPostProcessor接口的类来拦截初始化过程对花束进行修改或封装。在一切准备就绪后你的花束就已经完全初始化了。
展示过程中你需要不断调整花束的状态和位置以确保它们始终保持最佳状态。这就相当于Bean的生命周期中的实例化和初始化阶段。在这个过程中你可以使用反射机制或者Setter方法来注入属性并使用xml文件中声明的init-method属性来指定初始化方法。
当所有展示结束后你需要开始收拾花束并将它们妥善地保存。这就相当于Bean的销毁阶段。在这个阶段你可以使用实现了DisposableBean接口的类来执行一些自定义的销毁操作。你也可以使用xml文件中声明的destroy-method属性来指定销毁方法。
总之Spring Bean的生命周期就像是展示你的花艺作品一样。你需要在花展中找到空间、搭建花架、放置花束、调整花束的位置和状态、收拾花束和花架这个过程非常重要对于你的花艺作品和Spring应用的性能都至关重要。
Spring Bean线程安全
Spring Bean 的线程安全性是指在多线程环境下使用 Bean 时Bean 是否能够正确地执行其预期操作。当多个线程同时访问同一个 Bean 实例时可能会导致线程安全问题例如竞争条件、死锁等等。因此确保 Spring Bean 的线程安全性非常重要尤其是在高并发应用程序中。
Spring Framework 通过提供不同的 Bean 作用域来解决线程安全问题。作用域范围为 singleton 的 Bean 实例是线程不安全的因为单个实例将在整个应用程序中共享。如果多个线程同时访问相同的 singleton Bean 实例则可能会发生竞争条件和数据损坏。相反作用域范围为 prototype 的 Bean 实例是线程安全的因为每次请求 Bean 都会创建新的实例。
在 Spring 框架中还提供了其他作用域范围例如 request、session 和 global session 等等。这些范围意味着在请求、会话或全局会话期间创建的 Bean 实例仅由与该请求、会话或全局会话相关联的线程使用。因此它们是线程安全的。
需要注意的是即使在作用域范围为 prototype 的 Bean 实例中如果 Bean 的依赖项是线程不安全的则仍可能会出现线程安全问题。因此在涉及到 Bean 的依赖项时确保它们也是线程安全的。
Spring Bean 的实现原理
理解 Spring Bean 的线程安全性需要深入了解其实现原理。在 Spring Framework 中Bean 实例是通过 BeanFactory 或 ApplicationContext 接口来创建和管理的。BeanFactory 是 Spring Framework 的核心接口它提供了创建、配置和管理 Bean 实例的方法。ApplicationContext 是 BeanFactory 接口的子接口它提供了更高级别的特性例如事件发布、国际化和各种应用程序层次结构上下文。
当应用程序启动时Spring 容器将读取并解析 ApplicationContext 或 BeanFactory 配置并创建和初始化所有标记为 Bean 的类。在创建 Bean 时Spring 容器将根据 Bean 的作用域范围来决定创建新实例还是返回现有实例。如果 Bean 的作用域范围为 singletonSpring 容器将创建一个实例并在整个应用程序中共享该实例。如果 Bean 的作用域范围为 prototype则每次调用该 Bean 时都会创建一个新实例。无论哪种作用域都可以在 Bean 的定义中指定。
Spring Bean 的线程安全性取决于其作用域范围以及其依赖项的线程安全性。如果 Bean 的作用域范围为 prototype则每个线程将拥有自己的 Bean 实例并且不会相互干扰因此是线程安全的。如果 Bean 的作用域范围为 singleton并且 Bean 的依赖项是线程安全的则 Bean 也是线程安全的。如果 Bean 的依赖项是线程不安全的则该 Bean 在多线程环境中可能会存在线程安全问题即使是 prototype 作用域的 Bean。
在 Spring Framework 的实现中Bean 实例是由对象工厂创建的。这些对象工厂实际上是 Spring 容器的基本组成部分可以通过 BeanFactory 或 ApplicationContext 接口访问。对象工厂是一个工厂模式它封装了对象的实际创建过程。在创建 Bean 实例时对象工厂将使用 BeanDefinition 和 BeanWrapper 对象来指示如何创建和管理 Bean 实例。BeanDefinition 包含 Bean 的元数据信息例如名称、作用域、类名、构造函数参数、属性、依赖项等等。BeanWrapper 是一个包装器对象用于访问和操作 Bean 实例的属性。
Spring Bean 的线程安全性问题解决方案
为了解决 Spring Bean 的线程安全问题可以采用以下几种方法
1. 使用线程安全 Bean
Spring Framework 中有许多线程安全的 Bean 实现例如 ConcurrentHashMap、ConcurrentLinkedQueue、AtomicInteger 等等。如果可以使用线程安全的 Bean 实现来代替非线程安全的实现可以有效地解决线程安全问题。
2. 对 Bean 进行同步
对 Bean 进行同步是解决线程安全问题的另一种方法。可以使用 synchronized 关键字对 Bean 的方法进行同步以确保只有一个线程可以访问该方法。但这种方法会导致性能问题并且需要仔细设计同步块以避免死锁等问题。
3. 将 Bean 的作用域范围设为 prototype
将 Bean 的作用域范围设置为 prototype 是一种解决线程安全问题的简单方法。由于每次请求 Bean 时都会创建新实例因此不存在线程安全问题。但是由于每次请求 Bean 都会创建新实例这会导致性能问题并且可能导致内存泄漏。
4. 使用 AOP 实现线程安全性
使用 AOP面向切面编程可以轻松地解决 Spring Bean 的线程安全问题。可以使用 AOP 拦截器来确保 Bean 的方法只由一个线程访问。可以使用 Spring Framework 的 AspectJ 注释来创建拦截器并指定拦截器适用于哪些 Bean 和方法。
举例说明
想象一下你正在经营一家快餐店并且有很多顾客同时来到店里。如果你的服务员只有一张厨房订单那么他们可能会在交叉的订单上工作导致混乱和错误。为了确保订单正确无误你决定让每个服务员都有自己的订单本子他们可以在上面记录每个顾客的点餐内容。这样即使有多个服务员同时处理订单他们也不会相互干扰。
这就好比 Spring Bean 的线程安全问题。当多个线程同时访问同一个 Bean 实例时可能会导致线程安全问题例如竞争条件、死锁等等。为了解决这个问题Spring Framework 通过提供不同的 Bean 作用域来保证线程安全。作用域为 singleton 的 Bean 实例是线程不安全的因为单个实例将在整个应用程序中共享。如果多个线程同时访问相同的 singleton Bean 实例则可能会发生冲突和数据损坏。因此确保 Spring Bean 的线程安全性非常重要尤其是在高并发应用程序中。
Spring Framework 中使用对象工厂创建 Bean 实例并通过 BeanDefinition 和 BeanWrapper 对象来指示如何创建和管理 Bean 实例。其中BeanDefinition 包含 Bean 的元数据信息例如名称、作用域、类名、构造函数参数、属性、依赖项等等BeanWrapper 是一个包装器对象用于访问和操作 Bean 实例的属性。
如果 Bean 的作用域范围为 prototype则每次请求 Bean 时都会创建新实例因此不存在线程安全问题。但是由于每次请求 Bean 都会创建新实例这会导致性能问题并且可能导致内存泄漏。因此Spring Framework 中还提供了其他作用域范围例如 request、session 和 global session 等等。这些范围意味着在请求、会话或全局会话期间创建的 Bean 实例仅由与该请求、会话或全局会话相关联的线程使用因此是线程安全的。
为了解决 Spring Bean 的线程安全问题可以采用以下几种方法使用线程安全 Bean、对 Bean 进行同步、将 Bean 的作用域范围设为 prototype 或使用 AOP 实现线程安全性。采用不同的方法取决于具体情况和需求需要根据实际情况进行选择。
单例模式的单例Bean
单例模式是一种常见的设计模式它的核心思想是确保在应用程序中只存在一个实例对象以保证这个对象作为一个全局变量可以被访问和共享。在Spring框架中单例模式被广泛应用因为Spring要保证Bean的单例性确保在整个应用程序中只有一个实例对象这样可以提高性能并且保证数据的一致性。
在Spring中单例模式的Bean是我们最常用的一个模式它所扮演的角色是作为Spring容器中的一个重要组成部分由Spring容器来管理和维护。下面我们就来详细地了解一下Spring中单例Bean的实现原理。
首先我们需要知道Spring在启动时会将所有的Bean都加载到内存中这些Bean包括单例Bean和非单例Bean。对于非单例BeanSpring会在创建Bean的时候将其放入缓存中当有请求需要使用这个Bean的时候从缓存中取出即可。
而对于单例BeanSpring会在容器启动的时候创建并初始化它们然后将它们放入一个特殊的缓存中这个缓存被称为“单例池”Singleton Pool这个单例池是全局唯一的用于存放所有的单例Bean。
在Spring中单例Bean的创建和初始化过程是由BeanFactory完成的。BeanFactory是Spring容器的核心接口它的作用是管理Bean的生命周期和依赖关系。
当Spring容器启动时BeanFactory会根据XML配置文件或注解扫描器来创建Bean对象对于单例BeanBeanFactory会在创建Bean的过程中检查单例池中是否已经存在该Bean对象如果存在就直接返回否则就创建新的对象并将其放入单例池中。
对于每一个单例BeanSpring会为其创建一个代理对象这个代理对象被称为“早期BeanEarly Bean”它的作用是在单例Bean还没有被完全初始化之前提供一些基础的功能例如依赖注入、AOP切面等。然后Spring会按照Bean依赖关系的顺序逐个完成单例Bean的初始化工作直到所有的单例Bean都被初始化完成。
在单例Bean的生命周期中如果出现了一些异常情况例如Bean的构造器出现了错误Spring会将该Bean从单例池中移除避免出现后续错误。
需要注意的是虽然Spring中的单例Bean是线程安全的但是如果这些单例Bean保存了共享状态那么在并发场景下可能会出现问题。因此我们应该避免在单例Bean中保存共享状态并尽可能地采用无状态的方式来编写业务逻辑。
举个例子假设你是一名游戏开发者正在开发一款多人在线游戏。在游戏中玩家需要共享很多数据例如游戏关卡、角色等级和经验值等等。为了确保数据的一致性和节约内存空间你需要使用单例模式来管理这些数据。
你想到了一个方法那就是在游戏启动时创建一个GameManager类并将其设置为单例模式。GameManager类中保存了游戏的全部数据并提供了各种方法来对外暴露这些数据。在玩家进入游戏时GameManager会通过网络从服务器上获取最新的数据并更新本地数据。在玩家离开游戏时GameManager会将本地数据上传到服务器上。
在Spring中单例Bean的实现原理跟这个GameManager类有些相似。Spring会在启动时创建所有的单例Bean并将其放入一个单例池中。当有请求需要使用某个单例Bean时Spring会从单例池中获取它并返回给请求方。同时Spring会为每个单例Bean创建一个代理对象用于提供一些基础的功能例如依赖注入和AOP切面。最后Spring会按照Bean依赖关系的顺序逐个完成单例Bean的初始化工作。
需要注意的是如果在单例Bean中保存了共享状态可能会在并发场景下出现问题因此应该尽可能地避免这种情况。在编写业务逻辑时应该尽量采用无状态的方式将状态保存在局部变量中而不是保存在单例Bean中。
Spring AOP底层实现原理
Spring循环依赖
Spring容器启动流程
Spring事务及传播机制底层原理
Spring IOC容器加载过程与依赖注入
Spring的自动装配
Spring6.0核心新特性
Spring Boot自动装配
Spring Boot启动过程
Spring Framework的SPI机制
SpringMVC执行流程
Dubbo服务发现与调用
Dubbo容错机制
Dubbo负载均衡
Dubbo序列化协议
动态感知服务下线
ZooKeeper选举
脑裂与假死
Zab协议
Quorum机制
ACL访问控制列表
Configuration、Autowired、Resource、ComponentScan、Conditional、Lazy、Primary、Import、SpringBootApplication注解的底层实现
6.深入理解ElasticSearch 核心语法、倒排索引、底层原理与分组聚合查询、具备集群高可用实战经验、集群架构原理。有ElasticSearch调优经验如GC调优、索引优化设置、查询方面优化、数据结构优化、集群架构设计、慢查询优化、可用性优化、性能优化、执行引擎的优化、成本优化、扩展性优化、分析性能问题等。 7.熟练使用设计模式 不同营销策略的切换场景策略模式 对象的创建和管理场景工厂模式 奖励分配和活动参与场景责任链模式实时消息推送、互动交流场景发布-订阅模式 用户的行为响应和推送通知功能场景观察者模式 支付场景策略模式 工厂模式 门面模式 单例模式 业务投放场景责任链模式 平台积分红包发放场景装饰者模式 订单状态场景状态模式观察者模式 开具增值税发票场景 建造者模式 原型模式 商品多级分类目录场景组合模式访问者模式 记录核心审计日志场景 模板方法模式 查询ElasticSearch大量数据场景迭代器模式 8.抢购系统落地 以高并发、高性能、高可用的技术作为基础保障重点突破库存与限购、防刷与风控、数据一致、热点隔离、动静分离、削峰填谷、数据兜底、限流与降级、流控与容灾等核心技术问题。抢购系统所涉及到的最核心的技术内容 缓存设计多级缓存与库存分割 分离策略主从分离与动静分离 流量策略负载均衡与加权处理 数据库优化合理选择字段类型、索引设计、查询性能、分库分表垂直拆分与水平拆分 异步优化系统异步化、缓存队列、缓冲队列 代码异步化处理Servlet3异步化、使用MQ异步化、自定义异步化策略 使用多级缓存本地缓存、Redis缓存、数据库缓存 缓存问题缓存击穿、缓存穿透、缓存雪崩 合理使用锁注意锁粒度、锁的获取与释放、锁超时 池化技术线程池、连接池、对象池、缓冲区 SQL优化尽量走索引、尽量减少关联查询、查询数据量尽量少 物理机极致优化CPU模式优化、操作系统参数优化、套接字缓冲区优化、频繁接收大文件优化、网卡层面优化、TCP连接优化、Nginx优化、网关优化 单机Java进程极致优化JVM优化、Tomcat优化、线程模型优化、Servlet3异步化、RPC框架调优、资源静态化、Vertx异步化 隔离策略线程隔离、连接隔离、业务隔离、系统隔离、数据隔离、热点隔离动态热点与静态热点流量隔离、逻辑隔离、物理隔离 流量控制预约设计、缓存设计、动态感知、把控参与人数、设置人数上限 削峰与限流验证码、问答题、异步消息、分层过滤、服务网关限流、业务网关限流、应用层限流线程池限流与API限流 服务降级读服务降级、写服务降级、简化系统功能、舍弃非核心功能、数据兜底 热点数据读热点与写热点 服务容灾同机房多部署、多机房部署、同城双活、异地多活 库存扣减设计下单减库存、付款减库存、与扣减库存、库存扣减问题解决方案、秒杀系统扣减库存方案、Redis实现扣减库存、RedisLua解决超卖、Redis分割库存、商品维度限购、库存防超卖 限购规则商品维度限购与个人维度限购 防刷策略Nginx条件限流、Token机制防刷、布隆过滤器校验、黑名单机制 风控策略完善用户画像、丰富业务场景、不断优化算法 可复用于任何需要支撑高并发、大流量的业务场景 9.工作经验 能独立或带领团队Java工程师成员完成服务端代码的研发工作结合业务需求给出合理的技术解决方案改进现有模块功能提高系统的可扩展性封装性稳定性。深入挖掘业务需求可0-1设计高可用、高并发、高伸缩的分布式项目架构环境搭建、自动化部署、服务器环境线上排查、性能评估相关经验。拥有产品需求讨论、项目开发计划制定、控制项目风险、开发团队组建、技术小组日常管理、进度检验、成本管理、开发部署问题梳理、任务分配、负责指导、培训普通开发工程师、代码Review、审核开发工程师的设计与研发质量等经验。 10.项目经验 项目的业务背景、解决的问题、实现的效果和带来的价值提高了公司效率、降低成本、增加收益、在项目中的角色和能力、项目的整体架构和技术栈使用的框架、数据库、服务器、使用什么设计模式、优化技巧、性能调优、自己在项目中的角色和贡献参与的模块、负责的功能、解决的问题、在项目中的领导能力、带领团队完成项目、项目管理经验、给出真实的数据和指标使用的数据量、用户量、处理速度、项目的规模、项目的演示链接和作品代码演示链接。 读者大大们不要着急这篇文章估计要写个一周左右我会持续更新提前发出来想看看读者朋友们有什么好的建议给到我