技术总结
2024-01-17 19:47:32 0 举报
AI智能生成
在本次项目中,我们采用了先进的技术手段来实现了项目目标。首先,我们利用了大数据分析和机器学习算法来处理和分析海量的数据,从而得出有价值的信息和洞察。其次,我们采用了云计算技术来提供高效的数据存储和计算能力,确保了系统的可扩展性和稳定性。此外,我们还利用了区块链技术来实现数据的去中心化和安全性,保护了用户隐私和数据安全。最后,我们还采用了自动化测试和持续集成技术来确保软件质量和交付效率。通过这些技术的应用,我们成功地实现了项目的目标,并取得了良好的效果。
作者其他创作
大纲/内容
可见性 当一个线程修改了共享变量的值,其他线程能够看到修改的值。Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方法来实现可见性的。如何保证可见性通过 volatile 关键字保证可见性。通过 内存屏障保证可见性。通过 synchronized 关键字保证可见性。通过 Lock保证可见性。通过 final 关键字保证可见性
有序性即程序执行的顺序按照代码的先后顺序执行。JVM 存在指令重排,所以存在有序性问题。如何保证有序性通过 volatile 关键字保证可见性。通过 内存屏障保证可见性。通过 synchronized关键字保证有序性。通过 Lock保证有序性。
原子性一个或多个操作,要么全部执行且在执行过程中不被任何因素打断,要么全部不执行。在 Java 中,对基本数据类型的变量的读取和赋值操作是原子性操作(64位处理器)。不采取任何的原子性保障措施的自增操作并不是原子性的。如何保证原子性通过 synchronized 关键字保证原子性。通过 Lock保证原子性。通过 CAS保证原子性。
并发三大特性并发编程Bug的源头:可见性、原子性和有序性问题
volatile的特性可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性(基于这点,我们通过会认为volatile不具备原子性)。volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。 64位的long型和double型变量,只要它是volatile变量,对该变量的读/写就具有原子性。有序性:对volatile修饰的变量的读写操作前后加上各种特定的内存屏障来禁止指令重排序来保障有序性。在JSR-133之前的旧Java内存模型中,虽然不允许volatile变量之间重排序,但旧的Java内存模型允许volatile变量与普通变量重排序。为了提供一种比锁更轻量级的线程之间通信的机制,JSR-133专家组决定增强volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写-读和锁的释放-获取具有相同的内存语义。
volatile写-读的内存语义当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量。volatile可见性实现原理JMM内存交互层面实现volatile修饰的变量的read、load、use操作和assign、store、write必须是连续的,即修改后必须立即同步回主内存,使用时必须从主内存刷新,由此保证volatile变量操作对多线程的可见性。硬件层面实现通过lock前缀指令,会锁定变量缓存行区域并写回主内存,这个操作称为“缓存锁定”,缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域数据。一个处理器的缓存回写到内存会导致其他处理器的缓存无效。
JMM定义Java虚拟机规范中定义了Java内存模型(Java Memory Model,JMM),用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果,JMM规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。JMM描述的是一种抽象的概念,一组规则,通过这组规则控制程序中各个变量在共享数据区域和私有数据区域的访问方式,JMM是围绕原子性、有序性、可见性展开的。
JVM层面的内存屏障在JSR规范中定义了4种内存屏障:LoadLoad屏障:(指令Load1; LoadLoad; Load2),在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。LoadStore屏障:(指令Load1; LoadStore; Store2),在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。StoreStore屏障:(指令Store1; StoreStore; Store2),在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。StoreLoad屏障:(指令Store1; StoreLoad; Load2),在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能由于x86只有store load可能会重排序,所以只有JSR的StoreLoad屏障对应它的mfence或lock前缀指令,其他屏障对应空操作
指令重排序Java语言规范规定JVM线程内部维持顺序化语义。即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。指令重排序的意义:JVM能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。
总线窥探(Bus Snooping)总线窥探(Bus snooping)是缓存中的一致性控制器(snoopy cache)监视或窥探总线事务的一种方案,其目标是在分布式共享内存系统中维护缓存一致性。包含一致性控制器(snooper)的缓存称为snoopy缓存。该方案由Ravishankar和Goodman于1983年提出。工作原理当特定数据被多个缓存共享时,处理器修改了共享数据的值,更改必须传播到所有其他具有该数据副本的缓存中。这种更改传播可以防止系统违反缓存一致性。数据变更的通知可以通过总线窥探来完成。所有的窥探者都在监视总线上的每一个事务。如果一个修改共享缓存块的事务出现在总线上,所有的窥探者都会检查他们的缓存是否有共享块的相同副本。如果缓存中有共享块的副本,则相应的窥探者执行一个动作以确保缓存一致性。这个动作可以是刷新缓存块或使缓存块失效。它还涉及到缓存块状态的改变,这取决于缓存一致性协议(cache coherence protocol)。窥探协议类型根据管理写操作的本地副本的方式,有两种窥探协议:Write-invalidate当处理器写入一个共享缓存块时,其他缓存中的所有共享副本都会通过总线窥探失效。这种方法确保处理器只能读写一个数据的一个副本。其他缓存中的所有其他副本都无效。这是最常用的窥探协议。MSI、MESI、MOSI、MOESI和MESIF协议属于该类型。 Write-update当处理器写入一个共享缓存块时,其他缓存的所有共享副本都会通过总线窥探更新。这个方法将写数据广播到总线上的所有缓存中。它比write-invalidate协议引起更大的总线流量。这就是为什么这种方法不常见。Dragon和firefly协议属于此类别。
as-if-serial as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。 为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。
JMM
JDK8中的HashMap与JDK7的HashMap有什么不一样?1. JDK8中新增了红黑树,JDK8是通过数组+链表+红黑树来实现的2. JDK7中链表的插入是用的头插法,而JDK8中则改为了尾插法3. JDK8中的因为使用了红黑树保证了插入和查询了效率,所以实际上JDK8中的Hash算法实现的复杂度降低了4. JDK8中数组扩容的条件也发了变化,只会判断是否当前元素个数是否查过了阈值,而不再判断当前put进来的元素对应的数组下标位置是否有值。5. JDK7中是先扩容再添加新元素,JDK8中是先添加新元素然后再扩容
JDK8中链表转变为红黑树的条件?1. 链表中的元素的个数为8个或超过8个2. 同时,还要满足当前数组的长度大于或等于64才会把链表转变为红黑树。为什么?因为链表转变为红黑树的目的是为了解决链表过长,导致查询和插入效率慢的问题,而如果要解决这个问题,也可以通过数组扩容,把链表缩短也可以解决这个问题。所以在数组长度还不太长的情况,可以先通过数组扩容来解决链表过长的问题
HashMap扩容流程是怎样的?1. HashMap的扩容指的就是数组的扩容, 因为数组占用的是连续内存空间,所以数组的扩容其实只能新开一个新的数组,然后把老数组上的元素转移到新数组上来,这样才是数组的扩容2. 在HashMap中也是一样,先新建一个2被数组大小的数组3. 然后遍历老数组上的没一个位置,如果这个位置上是一个链表,就把这个链表上的元素转移到新数组上去4. 在这个过程中就需要遍历链表,当然jdk7,和jdk8在这个实现时是有不一样的,jdk7就是简单的遍历链表上的没一个元素,然后按每个元素的hashcode结合新数组的长度重新计算得出一个下标,而重新得到的这个数组下标很可能和之前的数组下标是不一样的,这样子就达到了一种效果,就是扩容之后,某个链表会变短,这也就达到了扩容的目的,缩短链表长度,提高了查询效率5. 而在jdk8中,因为涉及到红黑树,这个其实比较复杂,jdk8中其实还会用到一个双向链表来维护红黑树中的元素,所以jdk8中在转移某个位置上的元素时,会去判断如果这个位置是一个红黑树,那么会遍历该位置的双向链表,遍历双向链表统计哪些元素在扩容完之后还是原位置,哪些元素在扩容之后在新位置,这样遍历完双向链表后,就会得到两个子链表,一个放在原下标位置,一个放在新下标位置,如果原下标位置或新下标位置没有元素,则红黑树不用拆分,否则判断这两个子链表的长度,如果超过八,则转成红黑树放到对应的位置,否则把单向链表放到对应的位置。6. 元素转移完了之后,在把新数组对象赋值给HashMap的table属性,老数组会被回收到。
为什么HashMap的数组的大小是2的幂次方数?JDK7的HashMap是数组+链表实现的JDK8的HashMap是数组+链表+红黑树实现的当某个key-value对需要存储到数组中时,需要先生成一个数组下标index,并且这个index不能越界。在HashMap中,先得到key的hashcode,hashcode是一个数字,然后通过hashcode & (table.length - 1) 运算得到一个数组下标index,是通过与运算计算出来一个数组下标的,而不是通过取余,与运算相比于取余运算速度更快,但是也有一个前提条件,就是数组的长度得是一个2的幂次方数。
HashMap面
//无参构造器,构造一个容量大小为 10 的空的 list 集合,但构造函数只是给 elementData 赋值了一个空的数组,其实是在第一次添加元素时容量扩大至 10 的。
添加元素--默认尾部添加效率比较高
指定下标添加元素时间复杂度为O(n),与移动的元素个数正相关
扩容默认将扩容至原来容量的 1.5 倍然后将原数组中的数据复制到大小为 newCapacity 的新数组中,并将新数组赋值给 elementData。
迭代器 iteratorremove 方法的弊端。1、只能进行remove操作,add、clear 等 Itr 中没有。2、调用 remove 之前必须先调用 next。因为 remove 开始就对 lastRet 做了校验。而 lastRet 初始化时为 -1。3、next 之后只可以调用一次 remove。因为 remove 会将 lastRet 重新初始化为 -1
ArrayList:List特点:元素有放入顺序,元素可重复 。存储结构:底层采用数组来实现的。
LinkedList:存储结构:底层采用链表来实现的。
HashSet(Set):特点:元素无放入顺序,元素不可重复(注意:元素虽然无放入顺序,但是元素在set中的位置是有该元素的HashCode决定的,其位置其实是固定的) 存储结构: 底层采用HashMap来实现
HashMap(Map):特点:key,value存储,key可以为null,同样的key会被覆盖掉存储结构: 底层采用数组、链表、红黑树来实现的。原理讲解:哈希算法(也叫散列),就是把任意长度值(Key)通过散列算法变换成固定长度的key(地址)通过这个地址进行访问的数据结构它通过把关键码值映射到表中一个。位置来访问记录,以加快查找的速度。
ConcurrentHashMap(并发安全map):特点:并发安全的HashMap ,比Hashtable效率更高存储结构: 底层采用数组、链表、红黑树 内部大量采用CAS操作。并发控制使⽤synchronized 和 CAS 来操作来实现的。
集合
什么是 CASCAS(Compare And Swap,比较并交换),通常指的是这样一种原子操作:针对一个变量,首先比较它的内存值与某个期望值是否相同,如果相同,就给它赋一个新值。CAS可以看做是乐观锁(对比数据库的悲观、乐观锁)的一种实现方式,Java原子类中的递增操作就通过CAS自旋实现的。CAS是一种无锁算法,在不使用锁(没有线程被阻塞)的情况下实现多线程之间的变量同步。
compareAndSwapInt 为例,Unsafe 的 compareAndSwapInt 方法接收 4 个参数,分别 是:对象实例、内存偏移量、字段期望值、字段新值。该方法会针对指定对象实例中的相应偏移 量的字段执行 CAS 操作。
CAS缺陷CAS 虽然高效地解决了原子操作,但是还是存在一些缺陷的,主要表现在三个方面:自旋 CAS 长时间地不成功,则会给 CPU 带来非常大的开销 只能保证一个共享变量原子操作
ABA问题的解决方案数据库有个锁称为乐观锁,是一种基于数据版本实现数据同步的机制,每次修改一次数据,版本 就会进行累加。同样,Java也提供了相应的原子引用类AtomicStampedReference<V>reference即我们实际存储的变量,stamp是版本,每次修改可以通过+1保证版本唯一性。这样 就可以保证每次修改后的版本也会往上递增。
LongAdder的内部结构 LongAdder内部有一个base变量,一个Cell[]数组: base变量:非竞态条件下,直接累加到该变量上 Cell[]数组:竞态条件下,累加个各个线程自己的槽Cell[i]中
LongAdder#sum方法/**
LongAdder原理设计思路 AtomicLong中有个内部变量value保存着实际的long值,所有的操作都是针对该变 量进行。也就是说,高并发环境下,value变量其实是一个热点,也就是N个线程竞争 一个热点。LongAdder的基本思路就是分散热点,将value值分散到一个数组中,不 同线程会命中到数组的不同槽中,各个线程只对自己槽中的那个值进行CAS操作,这样热点就被分散了,冲突的概率就小很多。如果要获取真正的long值,只要将各个槽 中的变量值累加返回。
LongAccumulator是LongAdder的增强版。LongAdder只能针对数值的进行加减运算,而LongAccumulator提供了自定义的函数操作。其构造函数如下:通过LongBinaryOperator,可以自定义对入参的任意操作,并返回结果(LongBinaryOperator接收2个long作为参数,并返回1个long)。LongAccumulator内部原理和LongAdder几乎完全一样,都是利用了父类Striped64的longAccumulate方法。
CAS
AQS具备的特性: 阻塞等待队列共享/独占 公平/非公平 可重入 允许中断
AQS内部维护属性volatile int state state表示资源的可用状态
State三种访问方式: getState()setState() compareAndSetState()
AQS定义两种资源共享方式Exclusive-独占,只有一个线程能执行,如ReentrantLock Share-共享,多个线程可以同时执行,如Semaphore/CountDownLatch
AQS定义两种队列同步等待队列: 主要用于维护获取锁失败时入队的线程条件等待队列: 调用await()的时候会释放锁,然后线程会加入到条件队列,调用signal()唤醒的时候会把条件队列中的线程节点移动到同步队列中,等待再次获得锁
AQS 定义了5个队列中节点状态:1. 值为0,初始化状态,表示当前节点在sync队列中,等待着获取锁。2. CANCELLED,值为1,表示当前的线程被取消;3. SIGNAL,值为-1,表示当前节点的后继节点包含的线程需要运行,也就是unpark; 4. CONDITION,值为-2,表示当前节点在等待condition,也就是在condition队列 中;5. PROPAGATE,值为-3,表示当前场景下后续的acquireShared能够得以执行;
不同的自定义同步器竞争共享资源的方式也不同。自定义同步器在实现时只需要实现共享 资源state的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出 队等),AQS已经在顶层实现好了。自定义同步器实现时主要实现以下几种方法:isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现 它。tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。 tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。 tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。
条件等待队列AQS中条件队列是使用单向列表保存的,用nextWaiter来连接:调用await方法阻塞线程;当前线程存在于同步队列的头结点,调用await方法进行阻塞(从同步队列转化到条件队列)
Condition接口详解1. 调用Condition#await方法会释放当前持有的锁,然后阻塞当前线程,同时向Condition队列尾部添加一个节点,所以调用Condition#await方法的时候必须持有锁。2. 调用Condition#signal方法会将Condition队列的首节点移动到阻塞队列尾部,然后唤醒因调用Condition#await方法而阻塞的线程(唤醒之后这个线程就可以去竞争锁了),所以调用Condition#signal方法的时候必须持有锁,持有锁的线程唤醒被因调用Condition#await方法而阻塞的线程。等待唤醒机制之await/signal测试
相对于 synchronized, ReentrantLock具备如下特点:可中断 可以设置超时时间可以设置为公平锁 支持多个条件变量与 synchronized 一样,都支持可重入
ReentrantLock源码分析关注点: 1. ReentrantLock加锁解锁的逻辑2. 公平和非公 平,可重入锁的实现3. 线程竞争锁失败入队阻塞逻辑和获取锁的线程释放锁唤醒阻塞线程竞争锁的逻辑实现 ( 设计的精髓:并发场景下入队和出队操作)
ReentrantLock详解ReentrantLock是一种基于AQS框架的应用实现,是JDK中的一种线程并发访问的同步手段,它的功能类似于synchronized是一种互斥锁,可以保证线程安全。
Semaphore 常用方法构造器permits 表示许可证的数量(资源数)fair 表示公平性,如果这个设为 true 的话,下次执行的线程会是等待最久的线程常用方法acquire() 表示阻塞并获取许可tryAcquire() 方法在没有许可的情况下会立即返回 false,要获取许可的线程不会阻塞release() 表示释放许可int availablePermits():返回此信号量中当前可用的许可证数。int getQueueLength():返回正在等待获取许可证的线程数。boolean hasQueuedThreads():是否有线程正在等待获取许可证。void reducePermit(int reduction):减少 reduction 个许可证Collection getQueuedThreads():返回所有等待获取许可证的线程集合
Semaphore源码分析建议: 先跟上节课ReentrantLock的源码,再来跟Semaphore的源码关注点:1. Semaphore的加锁解锁(共享锁)逻辑实现2. 线程竞争锁失败入队阻塞逻辑和获取锁的线程释放锁唤醒阻塞线程竞争锁的逻辑实现
Semaphore介绍Semaphore,俗称信号量,它是操作系统中PV操作的原语在java的实现,它也是基于AbstractQueuedSynchronizer实现的。Semaphore的功能非常强大,大小为1的信号量就类似于互斥锁,通过同时只能有一个线程获取信号量实现。大小为n(n>0)的信号量可以实现限流的功能,它可以实现只能有n个线程同时获取信号量。
CountDownLatch应用场景CountDownLatch一般用作多线程倒计时计数器,强制它们等待其他一组(CountDownLatch的初始化决定)任务执行完成。CountDownLatch的两种使用场景:场景1:让多个线程等待场景2:让单个线程等待。
CountDownLatch实现原理底层基于 AbstractQueuedSynchronizer 实现,CountDownLatch 构造函数中指定的count直接赋给AQS的state;每次countDown()则都是release(1)减1,最后减到0时unpark阻塞线程;这一步是由最后一个执行countdown方法的线程执行的。而调用await()方法时,当前线程就会判断state属性是否为0,如果为0,则继续往下执行,如果不为0,则使当前线程进入等待状态,直到某个线程将state属性置为0,其就会唤醒在await()方法中等待的线程。
CountDownLatch与Thread.join的区别CountDownLatch的作用就是允许一个或多个线程等待其他线程完成操作,看起来有点类似join() 方法,但其提供了比 join() 更加灵活的API。CountDownLatch可以手动控制在n个线程里调用n次countDown()方法使计数器进行减一操作,也可以在一个线程里调用n次执行减一操作。而 join() 的实现原理是不停检查join线程是否存活,如果 join 线程存活则让当前线程永远等待。所以两者之间相对来说还是CountDownLatch使用起来较为灵活。CountDownLatch与CyclicBarrier的区别CountDownLatch和CyclicBarrier都能够实现线程之间的等待,只不过它们侧重点不同:CountDownLatch的计数器只能使用一次,而CyclicBarrier的计数器可以使用reset() 方法重置。所以CyclicBarrier能处理更为复杂的业务场景,比如如果计算发生错误,可以重置计数器,并让线程们重新执行一次CyclicBarrier还提供getNumberWaiting(可以获得CyclicBarrier阻塞的线程数量)、isBroken(用来知道阻塞的线程是否被中断)等方法。CountDownLatch会阻塞主线程,CyclicBarrier不会阻塞主线程,只会阻塞子线程。CountDownLatch和CyclicBarrier都能够实现线程之间的等待,只不过它们侧重点不同。CountDownLatch一般用于一个或多个线程,等待其他线程执行完任务后,再执行。CyclicBarrier一般用于一组线程互相等待至某个状态,然后这一组线程再同时执行。CyclicBarrier 还可以提供一个 barrierAction,合并多线程计算结果。CyclicBarrier是通过ReentrantLock的\"独占锁\"和Conditon来实现一组线程的阻塞唤醒的,而CountDownLatch则是通过AQS的“共享锁”实现
CountDownLatch介绍CountDownLatch(闭锁)是一个同步协助类,允许一个或多个线程等待,直到其他线程完成操作集。CountDownLatch使用给定的计数值(count)初始化。await方法会阻塞直到当前的计数值(count)由于countDown方法的调用达到0,count为0之后所有等待的线程都会被释放,并且随后对await方法的调用都会立即返回。这是一个一次性现象 —— count不会被重置。如果你需要一个重置count的版本,那么请考虑使用CyclicBarrier。
// parties表示屏障拦截的线程数量,每个线程调用 await 方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。 public CyclicBarrier(int parties) // 用于在线程到达屏障时,优先执行 barrierAction,方便处理更复杂的业务场景(该线程的执行时机是在到达屏障之后再执行)
CyclicBarrier与CountDownLatch的区别CountDownLatch的计数器只能使用一次,而CyclicBarrier的计数器可以使用reset() 方法重置。所以CyclicBarrier能处理更为复杂的业务场景,比如如果计算发生错误,可以重置计数器,并让线程们重新执行一次CyclicBarrier还提供getNumberWaiting(可以获得CyclicBarrier阻塞的线程数量)、isBroken(用来知道阻塞的线程是否被中断)等方法。CountDownLatch会阻塞主线程,CyclicBarrier不会阻塞主线程,只会阻塞子线程。CountDownLatch和CyclicBarrier都能够实现线程之间的等待,只不过它们侧重点不同。CountDownLatch一般用于一个或多个线程,等待其他线程执行完任务后,再执行。CyclicBarrier一般用于一组线程互相等待至某个状态,然后这一组线程再同时执行。CyclicBarrier 还可以提供一个 barrierAction,合并多线程计算结果。CyclicBarrier是通过ReentrantLock的\"独占锁\"和Conditon来实现一组线程的阻塞唤醒的,而CountDownLatch则是通过AQS的“共享锁”实现
CyclicBarrier源码分析关注点:1.一组线程在触发屏障之前互相等待,最后一个线程到达屏障后唤醒逻辑是如何实现的2.删栏循环使用是如何实现的3.条件队列到同步队列的转换实现逻辑
CyclicBarrier介绍字面意思回环栅栏,通过它可以实现让一组线程等待至某个状态(屏障点)之后再全部同时执行。叫做回环是因为当所有等待线程都被释放以后,CyclicBarrier可以被重用。
注意事项读锁不支持条件变量重入时升级不支持:持有读锁的情况下去获取写锁,会导致获取永久等待重入时支持降级: 持有写锁的情况下可以去获取读锁
锁降级锁降级指的是写锁降级成为读锁。如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级。锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程。锁降级可以帮助我们拿到当前线程修改后的结果而不被其他线程所破坏,防止更新丢失。锁降级中读锁的获取是否必要呢?答案是必要的。主要是为了保证数据的可见性,如果当前线程不获取读锁而是直接释放写锁,假设此刻另一个线程(记作线程T)获取了写锁并修改了数据,那么当前线程无法感知线程T的数据更新。如果当前线程获取读锁,即遵循锁降级的步骤,则线程T将会被阻塞,直到当前线程使用数据并释放读锁之后,线程T才能获取写锁进行数据更新。RentrantReadWriteLock不支持锁升级(把持读锁、获取写锁,最后释放读锁的过程)。目的也是保证数据可见性,如果读锁已被多个线程获取,其中任意线程成功获取了写锁并更新了数据,则其更新对其他获取到读锁的线程是不可见的。
ReentrantReadWriteLock结构
exclusiveCount(int c) 静态方法,获得持有写状态的锁的次数。sharedCount(int c) 静态方法,获得持有读状态的锁的线程数量。不同于写锁,读锁可以同时被多个线程持有。而每个线程持有的读锁支持重入的特性,所以需要对每个线程持有的读锁的数量单独计数,这就需要用到 HoldCounter 计数器
HoldCounter 计数器读锁的内在机制其实就是一个共享锁。一次共享锁的操作就相当于对HoldCounter 计数器的操作。获取共享锁,则该计数器 + 1,释放共享锁,该计数器 - 1。只有当线程获取共享锁后才能对共享锁进行释放、重入操作。通过 ThreadLocalHoldCounter 类,HoldCounter 与线程进行绑定。HoldCounter 是绑定线程的一个计数器,而 ThreadLocalHoldCounter 则是线程绑定的 ThreadLocal。HoldCounter是用来记录读锁重入数的对象ThreadLocalHoldCounter是ThreadLocal变量,用来存放不是第一个获取读锁的线程的其他线程的读锁重入数对象
写锁的获取写锁是一个支持重进入的排它锁。如果当前线程已经获取了写锁,则增加写状态。如果当前线程在获取写锁时,读锁已经被获取(读状态不为0)或者该线程不是已经获取写锁的线程, 则当前线程进入等待状态。写锁的获取是通过重写AQS中的tryAcquire方法实现的。
通过源码我们可以知道:读写互斥写写互斥写锁支持同一个线程重入writerShouldBlock写锁是否阻塞实现取决公平与非公平的策略(FairSync和NonfairSync)
写锁的释放写锁释放通过重写AQS的tryRelease方法实现
读锁的获取实现共享式同步组件的同步语义需要通过重写AQS的tryAcquireShared方法和tryReleaseShared方法。读锁的获取实现方法为:读锁共享,读读不互斥读锁可重入,每个获取读锁的线程都会记录对应的重入数读写互斥,锁降级场景除外支持锁降级,持有写锁的线程,可以获取读锁,但是后续要记得把读锁和写锁读释放readerShouldBlock读锁是否阻塞实现取决公平与非公平的策略(FairSync和NonfairSync)
读锁的释放获取到读锁,执行完临界区后,要记得释放读锁(如果重入多次要释放对应的次数),不然会阻塞其他线程的写操作。读锁释放的实现主要通过方法tryReleaseShared:
ReentrantReadWriteLock类结构ReentrantReadWriteLock是可重入的读写锁实现类。在它内部,维护了一对相关的锁,一个用于只读操作,另一个用于写入操作。只要没有 Writer 线程,读锁可以由多个 Reader 线程同时持有。也就是说,写锁是独占的,读锁是共享的。
读写锁介绍 现实中有这样一种场景:对共享资源有读和写的操作,且写操作没有读操作那么频繁(读多写少)。在没有写操作的时候,多个线程同时读一个资源没有任何问题,所以应该允许多个线程同时读取共享资源(读读可以并发);但是如果一个线程想去写这些共享资源,就不应该允许其他线程对该资源进行读和写操作了(读写,写读,写写互斥)。在读多于写的情况下,读写锁能够提供比排它锁更好的并发性和吞吐量。针对这种场景,JAVA的并发包提供了读写锁ReentrantReadWriteLock,它内部,维护了一对相关的锁,一个用于只读操作,称为读锁;一个用于写入操作,称为写锁,描述如下:线程进入读锁的前提条件:没有其他线程的写锁没有写请求或者有写请求,但调用线程和持有锁的线程是同一个。线程进入写锁的前提条件:没有其他线程的读锁没有其他线程的写锁而读写锁有以下三个重要的特性:公平选择性:支持非公平(默认)和公平的锁获取方式,吞吐量还是非公平优于公平。可重入:读锁和写锁都支持线程重入。以读写线程为例:读线程获取读锁后,能够再次获取读锁。写线程在获取写锁之后能够再次获取写锁,同时也可以获取读锁。锁降级:遵循获取写锁、再获取读锁最后释放写锁的次序,写锁能够降级成为读锁。
BlockingQueue接口BlockingQueue 继承了 Queue 接口,是队列的一种。Queue 和 BlockingQueue 都是在 Java 5 中加入的。阻塞队列(BlockingQueue)是一个在队列基础上又支持了两个附加操作的队列,常用解耦。两个附加操作:支持阻塞的插入方法put: 队列满时,队列会阻塞插入元素的线程,直到队列不满。支持阻塞的移除方法take: 队列空时,获取元素的线程会等待队列变为非空
阻塞队列特性阻塞阻塞队列区别于其他类型的队列的最主要的特点就是“阻塞”这两个字,所以下面重点介绍阻塞功能:阻塞功能使得生产者和消费者两端的能力得以平衡,当有任何一端速度过快时,阻塞队列便会把过快的速度给降下来。实现阻塞最重要的两个方法是 take 方法和 put 方法。take 方法take 方法的功能是获取并移除队列的头结点,通常在队列里有数据的时候是可以正常移除的。可是一旦执行 take 方法的时候,队列里无数据,则阻塞,直到队列里有数据。一旦队列里有数据了,就会立刻解除阻塞状态,并且取到数据。put 方法put 方法插入元素时,如果队列没有满,那就和普通的插入一样是正常的插入,但是如果队列已满,那么就无法继续插入,则阻塞,直到队列里有了空闲空间。如果后续队列有了空闲空间,比如消费者消费了一个元素,那么此时队列就会解除阻塞状态,并把需要添加的数据添加到队列中。是否有界阻塞队列还有一个非常重要的属性,那就是容量的大小,分为有界和无界两种。无界队列意味着里面可以容纳非常多的元素,例如 LinkedBlockingQueue 的上限是 Integer.MAX_VALUE,是非常大的一个数,可以近似认为是无限容量,因为我们几乎无法把这个容量装满。但是有的阻塞队列是有界的,例如 ArrayBlockingQueue 如果容量满了,也不会扩容,所以一旦满了就无法再往里放数据了。
ArrayBlockingQueue的原理数据结构利用了Lock锁的Condition通知机制进行阻塞控制。核心:一把锁,两个条件//数据元素数组
ArrayBlockingQueueArrayBlockingQueue是最典型的有界阻塞队列,其内部是用数组存储元素的,初始化时需要指定容量大小,利用 ReentrantLock 实现线程安全。在生产者-消费者模型中使用时,如果生产速度和消费速度基本匹配的情况下,使用ArrayBlockingQueue是个不错选择;当如果生产速度远远大于消费速度,则会导致队列填满,大量生产线程被阻塞。使用独占锁ReentrantLock实现线程安全,入队和出队操作使用同一个锁对象,也就是只能有一个线程可以进行入队或者出队操作;这也就意味着生产者和消费者无法并行操作,在高并发场景下会成为性能瓶颈。
LinkedBlockingQueueLinkedBlockingQueue是一个基于链表实现的阻塞队列,默认情况下,该阻塞队列的大小为Integer.MAX_VALUE,由于这个数值特别大,所以 LinkedBlockingQueue 也被称作无界队列,代表它几乎没有界限,队列可以随着元素的添加而动态增长,但是如果没有剩余内存,则队列将抛出OOM错误。所以为了避免队列过大造成机器负载或者内存爆满的情况出现,我们在使用的时候建议手动传一个队列的大小。LinkedBlockingQueue内部由单链表实现,只能从head取元素,从tail添加元素。LinkedBlockingQueue采用两把锁的锁分离技术实现入队出队互不阻塞,添加元素和获取元素都有独立的锁,也就是说LinkedBlockingQueue是读写分离的,读写操作可以并行执行。
LinkedBlockingQueue与ArrayBlockingQueue对比LinkedBlockingQueue是一个阻塞队列,内部由两个ReentrantLock来实现出入队列的线程安全,由各自的Condition对象的await和signal来实现等待和唤醒功能。它和ArrayBlockingQueue的不同点在于:队列大小有所不同,ArrayBlockingQueue是有界的初始化必须指定大小,而LinkedBlockingQueue可以是有界的也可以是无界的(Integer.MAX_VALUE),对于后者而言,当添加速度大于移除速度时,在无界的情况下,可能会造成内存溢出等问题。数据存储容器不同,ArrayBlockingQueue采用的是数组作为数据存储容器,而LinkedBlockingQueue采用的则是以Node节点作为连接对象的链表。由于ArrayBlockingQueue采用的是数组的存储容器,因此在插入或删除元素时不会产生或销毁任何额外的对象实例,而LinkedBlockingQueue则会生成一个额外的Node对象。这可能在长时间内需要高效并发地处理大批量数据的时,对于GC可能存在较大影响。两者的实现队列添加或移除的锁不一样,ArrayBlockingQueue实现的队列中的锁是没有分离的,即添加操作和移除操作采用的同一个ReenterLock锁,而LinkedBlockingQueue实现的队列中的锁是分离的,其添加采用的是putLock,移除采用的则是takeLock,这样能大大提高队列的吞吐量,也意味着在高并发的情况下生产者和消费者可以并行地操作队列中的数据,以此来提高整个队列的并发性能。
如图所示,SynchronousQueue 最大的不同之处在于,它的容量为 0,所以没有一个地方来暂存元素,导致每次取数据都要先阻塞,直到有数据被放入;同理,每次放数据的时候也会阻塞,直到有消费者来取。需要注意的是,SynchronousQueue 的容量不是 1 而是 0,因为 SynchronousQueue 不需要去持有元素,它所做的就是直接传递(direct handoff)。由于每当需要传递的时候,SynchronousQueue 会把元素直接从生产者传给消费者,在此期间并不需要做存储,所以如果运用得当,它的效率是很高的。
应用场景SynchronousQueue非常适合传递性场景做交换工作,生产者的线程和消费者的线程同步传递某些信息、事件或者任务。SynchronousQueue的一个使用场景是在线程池里。如果我们不确定来自生产者请求数量,但是这些请求需要很快的处理掉,那么配合SynchronousQueue为每个生产者请求分配一个消费线程是处理效率最高的办法。Executors.newCachedThreadPool()就使用了SynchronousQueue,这个线程池根据需要(新任务到来时)创建新的线程,如果有空闲线程则会重复使用,线程空闲了60秒后会被回收。
SynchronousQueueSynchronousQueue是一个没有数据缓冲的BlockingQueue,生产者线程对其的插入操作put必须等待消费者的移除操作take。
DelayQueueDelayQueue 是一个支持延时获取元素的阻塞队列, 内部采用优先队列 PriorityQueue 存储元素,同时元素必须实现 Delayed 接口;在创建元素时可以指定多久才可以从队列中获取当前元素,只有在延迟期满时才能从队列中提取元素。延迟队列的特点是:不是先进先出,而是会按照延迟时间的长短来排序,下一个即将执行的任务会排到队列的最前面。它是无界队列,放入的元素必须实现 Delayed 接口,而 Delayed 接口又继承了 Comparable 接口,所以自然就拥有了比较和排序的能力,
如何选择适合的阻塞队列线程池对于阻塞队列的选择线程池有很多种,不同种类的线程池会根据自己的特点,来选择适合自己的阻塞队列。FixedThreadPool(SingleThreadExecutor 同理)选取的是 LinkedBlockingQueueCachedThreadPool 选取的是 SynchronousQueueScheduledThreadPool(SingleThreadScheduledExecutor同理)选取的是延迟队列选择策略通常我们可以从以下 5 个角度考虑,来选择合适的阻塞队列:功能第 1 个需要考虑的就是功能层面,比如是否需要阻塞队列帮我们排序,如优先级排序、延迟执行等。如果有这个需要,我们就必须选择类似于 PriorityBlockingQueue 之类的有排序能力的阻塞队列。容量第 2 个需要考虑的是容量,或者说是否有存储的要求,还是只需要“直接传递”。在考虑这一点的时候,我们知道前面介绍的那几种阻塞队列,有的是容量固定的,如 ArrayBlockingQueue;有的默认是容量无限的,如 LinkedBlockingQueue;而有的里面没有任何容量,如 SynchronousQueue;而对于 DelayQueue 而言,它的容量固定就是 Integer.MAX_VALUE。所以不同阻塞队列的容量是千差万别的,我们需要根据任务数量来推算出合适的容量,从而去选取合适的 BlockingQueue。能否扩容第 3 个需要考虑的是能否扩容。因为有时我们并不能在初始的时候很好的准确估计队列的大小,因为业务可能有高峰期、低谷期。如果一开始就固定一个容量,可能无法应对所有的情况,也是不合适的,有可能需要动态扩容。如果我们需要动态扩容的话,那么就不能选择 ArrayBlockingQueue ,因为它的容量在创建时就确定了,无法扩容。相反,PriorityBlockingQueue 即使在指定了初始容量之后,后续如果有需要,也可以自动扩容。所以我们可以根据是否需要扩容来选取合适的队列。内存结构第 4 个需要考虑的点就是内存结构。我们分析过 ArrayBlockingQueue 的源码,看到了它的内部结构是“数组”的形式。和它不同的是,LinkedBlockingQueue 的内部是用链表实现的,所以这里就需要我们考虑到,ArrayBlockingQueue 没有链表所需要的“节点”,空间利用率更高。所以如果我们对性能有要求可以从内存的结构角度去考虑这个问题。性能第 5 点就是从性能的角度去考虑。比如 LinkedBlockingQueue 由于拥有两把锁,它的操作粒度更细,在并发程度高的时候,相对于只有一把锁的 ArrayBlockingQueue 性能会更好。另外,SynchronousQueue 性能往往优于其他实现,因为它只需要“直接传递”,而不需要存储的过程。如果我们的场景需要直接传递的话,可以优先考虑 SynchronousQueue。
使用一个按顺序排列的有序向量实现优先级队列获取优先级最高元素,O(1)删除优先级最高元素,O(1)插入一个元素,需要两个步骤,第一步我们需要找出要插的位置,这里我们可以使用二分查找,成本为O(logn),第二步是插入元素之后,将其所有后继进行后移操作,成本为O(n),所有总成本为O(logn)+O(n)=O(n)
二叉堆完全二叉树:除了最后一行,其他行都满的二叉树,而且最后一行所有叶子节点都从左向右开始排序。二叉堆:完全二叉树的基础上,加以一定的条件约束的一种特殊的二叉树。根据约束条件的不同,二叉堆又可以分为两个类型:大顶堆和小顶堆。大顶堆(最大堆):父结点的键值总是大于或等于任何一个子节点的键值;小顶堆(最小堆):父结点的键值总是小于或等于任何一个子节点的键值。
PriorityBlockingQueuePriorityBlockingQueue是一个无界的基于数组的优先级阻塞队列,数组的默认长度是11,虽然指定了数组的长度,但是可以无限的扩充,直到资源消耗尽为止,每次出队都返回优先级别最高的或者最低的元素。默认情况下元素采用自然顺序升序排序,当然我们也可以通过构造函数来指定Comparator来对元素进行排序。需要注意的是PriorityBlockingQueue不能保证同优先级元素的顺序。优先级队列PriorityQueue: 队列中每个元素都有一个优先级,出队的时候,优先级最高的先出。
常见阻塞队列BlockingQueue 接口的实现类都被放在了 juc 包中,它们的区别主要体现在存储结构上或对元素操作上的不同,但是对于take与put操作的原理,却是类似的。ArrayBlockingQueue基于数组结构实现的一个有界阻塞队列LinkedBlockingQueue基于链表结构实现的一个有界阻塞队列PriorityBlockingQueue支持按优先级排序的无界阻塞队列DelayQueue基于优先级队列(PriorityBlockingQueue)实现的无界阻塞队列SynchronousQueue不存储元素的阻塞队列LinkedTransferQueue基于链表结构实现的一个无界阻塞队列LinkedBlockingDeque基于链表结构实现的一个双端阻塞队列
阻塞队列介绍Queue接口
AQS
executoer方法
ThreadPoolExecutor内部有实现4个拒绝策略:(1)、CallerRunsPolicy,由调用execute方法提交任务的线程来执行这个任务;(2)、AbortPolicy,抛出异常RejectedExecutionException拒绝提交任务;(3)、DiscardPolicy,直接抛弃任务,不做任何处理;(4)、DiscardOldestPolicy,去除任务队列中的第一个任务(最旧的),重新提交;
ScheduledThreadPoolExecutor: schedule:延迟多长时间之后只执行一次; scheduledAtFixedRate固定:延迟指定时间后执行一次,之后按照固定的时长周期执行; scheduledWithFixedDelay非固定:延迟指定时间后执行一次,之后按照:上一次任务执行时长 + 周期的时长 的时间去周期执行; DelayQueue内部封装了一个PriorityQueue,它会根据time的先后时间排序(time小的排在前面),若time相同则根据sequenceNumber排序( sequenceNumber小的排在前面);最小堆算法
linuxps -fe 查看所有进程ps -fT -p <PID> 查看某个进程(PID)的所有线程 kill 杀死进程top 按大写 H 切换是否显示线程top -H -p <PID> 查看某个进程(PID)的所有线程Javajps 命令查看所有 Java 进程jstack <PID> 查看某个 Java 进程(PID)的所有线程状态 jconsole 来查看某个 Java 进程中线程的运行情况(图形界面)
Java 语言中线程共有六种状态,分别是:NEW(初始化状态)RUNNABLE(可运行状态+运行状态)BLOCKED(阻塞状态)WAITING(无时限等待)TIMED_WAITING(有时限等待)TERMINATED(终止状态)在操作系统层面,Java 线程中的 BLOCKED、WAITING、TIMED_WAITING 是一种状态,即前面我们提到的休眠状态。也就是说只要 Java 线程处于这三种状态之一,那么这个线程就永远没有 CPU 的使用权。
sleep可以被中断 抛出中断异常:sleep interrupted, 清除中断标志位wait可以被中断 抛出中断异常:InterruptedException, 清除中断标志位
Java线程的中断机制Java没有提供一种安全、直接的方法来停止某个线程,而是提供了中断机制。中断机制是一种协作机制,也就是说通过中断并不能直接终止另一个线程,而需要被中断的线程自己处理。被中断的线程拥有完全的自主权,它既可以选择立即停止,也可以选择一段时间后停止,也可以选择压根不停止。API的使用interrupt(): 将线程的中断标志位设置为true,不会停止线程isInterrupted(): 判断当前线程的中断标志位是否为true,不会清除中断标志位Thread.interrupted():判断当前线程的中断标志位是否为true,并清除中断标志位,重置为faslepublic class ThreadInterruptTest {
线程
任务类型思考: 线程池的线程数设置多少合适?我们调整线程池中的线程数量的最主要的目的是为了充分并合理地使用 CPU 和内存等资源,从而最大限度地提高程序的性能。在实际工作中,我们需要根据任务类型的不同选择对应的策略。CPU密集型任务CPU密集型任务也叫计算密集型任务,比如加密、解密、压缩、计算等一系列需要大量耗费 CPU 资源的任务。对于这样的任务最佳的线程数为 CPU 核心数的 1~2 倍,如果设置过多的线程数,实际上并不会起到很好的效果。此时假设我们设置的线程数量是 CPU 核心数的 2 倍以上,因为计算任务非常重,会占用大量的 CPU 资源,所以这时 CPU 的每个核心工作基本都是满负荷的,而我们又设置了过多的线程,每个线程都想去利用 CPU 资源来执行自己的任务,这就会造成不必要的上下文切换,此时线程数的增多并没有让性能提升,反而由于线程数量过多会导致性能下降。IO密集型任务IO密集型任务,比如数据库、文件的读写,网络通信等任务,这种任务的特点是并不会特别消耗 CPU 资源,但是 IO 操作很耗时,总体会占用比较多的时间。对于这种任务最大线程数一般会大于 CPU 核心数很多倍,因为 IO 读写速度相比于 CPU 的速度而言是比较慢的,如果我们设置过少的线程数,就可能导致 CPU 资源的浪费。而如果我们设置更多的线程数,那么当一部分线程正在等待 IO 的时候,它们此时并不需要 CPU 来计算,那么另外的线程便可以利用 CPU 去执行其他的任务,互不影响,这样的话在工作队列中等待的任务就会减少,可以更好地利用资源。
线程数计算方法《Java并发编程实战》的作者 Brain Goetz 推荐的计算方法:线程数 = CPU 核心数 *(1+平均等待时间/平均工作时间)通过这个公式,我们可以计算出一个合理的线程数量,如果任务的平均等待时间长,线程数就随之增加,而如果平均工作时间长,也就是对于我们上面的 CPU 密集型任务,线程数就随之减少。太少的线程数会使得程序整体性能降低,而过多的线程也会消耗内存等其他资源,所以如果想要更准确的话,可以进行压测,监控 JVM 的线程情况以及 CPU 的负载情况,根据实际情况衡量应该创建的线程数,合理并充分利用资源。
分治算法分治算法的基本思想是将一个规模为N的问题分解为K个规模较小的子问题,这些子问题相互独立且与原问题性质相同。求出子问题的解,就可得到原问题的解。分治算法的步骤如下:分解:将要解决的问题划分成若干规模较小的同类问题;求解:当子问题划分得足够小时,用较简单的方法解决;合并:按原问题的要求,将子问题的解逐层合并构成原问题的解。
应用场景分治思想在很多领域都有广泛的应用,例如算法领域有分治算法(归并排序、快速排序都属于分治算法,二分法查找也是一种分治算法);大数据领域知名的计算框架 MapReduce 背后的思想也是分治。既然分治这种任务模型如此普遍,那 Java 显然也需要支持,Java 并发包里提供了一种叫做 Fork/Join 的并行计算框架,就是用来支持分治这种任务模型的。
Fork/Join框架介绍传统线程池ThreadPoolExecutor有两个明显的缺点:一是无法对大任务进行拆分,对于某个任务只能由单线程执行;二是工作线程从队列中获取任务时存在竞争情况。这两个缺点都会影响任务的执行效率。为了解决传统线程池的缺陷,Java7中引入Fork/Join框架,并在Java8中得到广泛应用。Fork/Join框架的核心是ForkJoinPool类,它是对AbstractExecutorService类的扩展。ForkJoinPool允许其他线程向它提交任务,并根据设定将这些任务拆分为粒度更细的子任务,这些子任务将由ForkJoinPool内部的工作线程来并行执行,并且工作线程之间可以窃取彼此之间的任务。ForkJoinPool最适合计算密集型任务,而且最好是非阻塞任务。ForkJoinPool是ThreadPoolExecutor线程池的一种补充,是对计算密集型场景的加强。根据经验和实验,任务总数、单任务执行耗时以及并行数都会影响到Fork/Join的性能。所以,当你使用Fork/Join框架时,你需要谨慎评估这三个指标,最好能通过模拟对比评估,不要凭感觉冒然在生产环境使用。
ForkJoinPool中有四个核心参数,用于控制线程池的并行数、工作线程的创建、异常处理和模式指定等。各参数解释如下:int parallelism:指定并行级别(parallelism level)。ForkJoinPool将根据这个设定,决定工作线程的数量。如果未设置的话,将使用Runtime.getRuntime().availableProcessors()来设置并行级别;ForkJoinWorkerThreadFactory factory:ForkJoinPool在创建线程时,会通过factory来创建。注意,这里需要实现的是ForkJoinWorkerThreadFactory,而不是ThreadFactory。如果你不指定factory,那么将由默认的DefaultForkJoinWorkerThreadFactory负责线程的创建工作;UncaughtExceptionHandler handler:指定异常处理器,当任务在运行中出错时,将由设定的处理器处理;boolean asyncMode:设置队列的工作模式:asyncMode ? FIFO_QUEUE : LIFO_QUEUE。当asyncMode为true时,将使用先进先出队列,而为false时则使用后进先出的模式。
execute类型的方法在提交任务后,不会返回结果。ForkJoinPool不仅允许提交ForkJoinTask类型任务,还允许提交Runnable任务执行Runnable类型任务时,将会转换为ForkJoinTask类型。由于任务是不可切分的,所以这类任务无法获得任务拆分这方面的效益,不过仍然可以获得任务窃取带来的好处和性能提升。
invoke方法接受ForkJoinTask类型的任务,并在任务执行结束后,返回泛型结果。如果提交的任务是null,将抛出空指针异常。
submit方法支持三种类型的任务提交:ForkJoinTask类型、Callable类型和Runnable类型。在提交任务后,将返回ForkJoinTask类型的结果。如果提交的任务是null,将抛出空指针异常,并且当任务不能按计划执行的话,将抛出任务拒绝异常。
按类型提交不同任务任务提交是ForkJoinPool的核心能力之一,提交任务有三种方式:
Fork/Join的使用Fork/Join 计算框架主要包含两部分,一部分是分治任务的线程池 ForkJoinPool,另一部分是分治任务 ForkJoinTaskForkJoinPoolForkJoinPool 是用于执行 ForkJoinTask 任务的执行池,不再是传统执行池 Worker+Queue 的组合式,而是维护了一个队列数组 WorkQueue(WorkQueue[]),这样在提交任务和线程任务的时候大幅度减少碰撞。
ForkJoinTaskForkJoinTask是ForkJoinPool的核心之一,它是任务的实际载体,定义了任务执行时的具体逻辑和拆分逻辑。ForkJoinTask继承了Future接口,所以也可以将其看作是轻量级的Future。ForkJoinTask 是一个抽象类,它的方法有很多,最核心的是 fork() 方法和 join() 方法,承载着主要的任务协调作用,一个用于任务提交,一个用于结果获取。fork()——提交任务fork()方法用于向当前任务所运行的线程池中提交任务。如果当前线程是ForkJoinWorkerThread类型,将会放入该线程的工作队列,否则放入common线程池的工作队列中。join()——获取任务执行结果join()方法用于获取任务的执行结果。调用join()时,将阻塞当前线程直到对应的子任务完成运行并返回结果。通常情况下我们不需要直接继承ForkJoinTask类,而只需要继承它的子类,Fork/Join框架提供了以下三个子类:RecursiveAction:用于递归执行但不需要返回结果的任务。RecursiveTask :用于递归执行需要返回结果的任务。CountedCompleter<T> :在任务完成执行后会触发执行一个自定义的钩子函数
ForkJoinTask使用限制ForkJoinTask最适合用于纯粹的计算任务,也就是纯函数计算,计算过程中的对象都是独立的,对外部没有依赖。提交到ForkJoinPool中的任务应避免执行阻塞I/O。
ForkJoinPool 的工作原理ForkJoinPool 内部有多个工作队列,当我们通过 ForkJoinPool 的 invoke() 或者 submit() 方法提交任务时,ForkJoinPool 根据一定的路由规则把任务提交到一个工作队列中,如果任务在执行过程中会创建出子任务,那么子任务会提交到工作线程对应的工作队列中。ForkJoinPool 的每个工作线程都维护着一个工作队列(WorkQueue),这是一个双端队列(Deque),里面存放的对象是任务(ForkJoinTask)。每个工作线程在运行中产生新的任务(通常是因为调用了 fork())时,会放入工作队列的top,并且工作线程在处理自己的工作队列时,使用的是 LIFO 方式,也就是说每次从top取出任务来执行。每个工作线程在处理自己的工作队列同时,会尝试窃取一个任务,窃取的任务位于其他线程的工作队列的base,也就是说工作线程在窃取其他工作线程的任务时,使用的是FIFO 方式。在遇到 join() 时,如果需要 join 的任务尚未完成,则会先处理其他任务,并等待其完成。在既没有自己的任务,也没有可以窃取的任务时,进入休眠 。工作窃取ForkJoinPool与ThreadPoolExecutor有个很大的不同之处在于,ForkJoinPool存在引入了工作窃取设计,它是其性能保证的关键之一。工作窃取,就是允许空闲线程从繁忙线程的双端队列中窃取任务。默认情况下,工作线程从它自己的双端队列的头部获取任务。但是,当自己的任务为空时,线程会从其他繁忙线程双端队列的尾部中获取任务。这种方法,最大限度地减少了线程竞争任务的可能性。ForkJoinPool的大部分操作都发生在工作窃取队列(work-stealing queues ) 中,该队列由内部类WorkQueue实现。它是Deques的特殊形式,但仅支持三种操作方式:push、pop和poll(也称为窃取)。在ForkJoinPool中,队列的读取有着严格的约束,push和pop仅能从其所属线程调用,而poll则可以从其他线程调用。
工作窃取算法的优点是充分利用线程进行并行计算,并减少了线程间的竞争;工作窃取算法缺点是在某些情况下还是存在竞争,比如双端队列里只有一个任务时。并且消耗了更多的系统资源,比如创建多个线程和多个双端队列。思考:为什么这么设计,工作线程总是从头部获取任务,窃取线程从尾部获取任务?这样做的主要原因是为了提高性能,通过始终选择最近提交的任务,可以增加资源仍分配在CPU缓存中的机会,这样CPU处理起来要快一些。而窃取者之所以从尾部获取任务,则是为了降低线程之间的竞争可能,毕竟大家都从一个部分拿任务,竞争的可能要大很多。此外,这样的设计还有一种考虑。由于任务是可分割的,那队列中较旧的任务最有可能粒度较大,因为它们可能还没有被分割,而空闲的线程则相对更有“精力”来完成这些粒度较大的任务。
工作队列WorkQueueWorkQueue 是双向列表,用于任务的有序执行,如果 WorkQueue 用于自己的执行线程 Thread,线程默认将会从尾端选取任务用来执行 LIFO。每个 ForkJoinWorkThread 都有属于自己的 WorkQueue,但不是每个 WorkQueue 都有对应的 ForkJoinWorkThread。没有 ForkJoinWorkThread 的 WorkQueue 保存的是 submission,来自外部提交,在WorkQueues[] 的下标是 偶数 位。
ForkJoinWorkThreadForkJoinWorkThread 是用于执行任务的线程,用于区别使用非 ForkJoinWorkThread 线程提交task。启动一个该 Thread,会自动注册一个 WorkQueue 到 Pool,拥有 Thread 的 WorkQueue 只能出现在 WorkQueues[] 的 奇数 位。
ForkJoinPool执行流程https://www.processon.com/view/link/5db81f97e4b0c55537456e9a总结Fork/Join是一种基于分治算法的模型,在并发处理计算型任务时有着显著的优势。其效率的提升主要得益于两个方面:任务切分:将大的任务分割成更小粒度的小任务,让更多的线程参与执行;任务窃取:通过任务窃取,充分地利用空闲线程,并减少竞争。在使用ForkJoinPool时,需要特别注意任务的类型是否为纯函数计算类型,也就是这些任务不应该关心状态或者外界的变化,这样才是最安全的做法。如果是阻塞类型任务,那么你需要谨慎评估技术方案。虽然ForkJoinPool也能处理阻塞类型任务,但可能会带来复杂的管理成本。
ForkJoin
1. juc下的队列大部分采用加ReentrantLock锁方式保证线程安全。在稳定性要求特别高的系统 中,为了防止生产者速度过快,导致内存溢出,只能选择有界队列。2. 加锁的方式通常会严重影响性能。线程会因为竞争不到锁而被挂起,等待其他线程释放锁而唤 醒,这个过程存在很大的开销,而且存在死锁的隐患。3. 有界队列通常采用数组实现。但是采用数组实现又会引发另外一个问题false sharing(伪 共享)。
Disruptor简介Disruptor是英国外汇交易公司LMAX开发的一个高性能队列,研发的初衷是解决内存队列的延迟问题(在性能测试中发现竟然与I/O操作处于同样的数量级)。基于Disruptor开发的系统单线程能支撑每秒600万订单,2010年在QCon演讲后,获得了业界关注。2011年,企业应用软件专家Martin Fowler专门撰写长文介绍。同年它还获得了Oracle官方的Duke大奖。目前,包括Apache Storm、Camel、Log4j 2在内的很多知名项目都应用了Disruptor以获取高性能。注意,这里所说的队列是系统内部的内存队列,而不是Kafka这样的分布式队列。Github:https://github.com/LMAX-Exchange/disruptorDisruptor实现了队列的功能并且是一个有界队列,可以用于生产者-消费者模型。
Disruptor的设计方案Disruptor通过以下设计来解决队列速度慢的问题:环形数组结构为了避免垃圾回收,采用数组而非链表。同时,数组对处理器的缓存机制更加友好(空间局部性原理)。元素位置定位数组长度2^n,通过位运算,加快定位的速度。下标采取递增的形式。不用担心index溢出的问题。index是long类型,即使100万QPS的处理速度,也需要30万年才能用完。无锁设计每个生产者或者消费者线程,会先申请可以操作的元素在数组中的位置,申请到之后,直接在该位置写入或者读取数据。利用缓存行填充解决了伪共享的问题实现了基于事件驱动的生产者消费者模型(观察者模式) 消费者时刻关注着队列里有没有消息,一旦有新消息产生,消费者线程就会立刻把它消费
一个生产者单线程写数据的流程申请写入m个元素;若是有m个元素可以写入,则返回最大的序列号。这里主要判断是否会覆盖未读的元素;若是返回的正确,则生产者开始写入元素。
多个生产者写数据的流程多个生产者的情况下,会遇到“如何防止多个线程重复写同一个元素”的问题。Disruptor的解决方法是每个线程获取不同的一段数组空间进行操作。这个通过CAS很容易达到。只需要在分配元素的时候,通过CAS判断一下这段空间是否已经分配出去即可。但是会遇到一个新问题:如何防止读取的时候,读到还未写的元素。Disruptor在多个生产者的情况下,引入了一个与Ring Buffer大小相同的buffer:available Buffer。当某个位置写入成功的时候,便把availble Buffer相应的位置置位,标记为写入成功。读取的时候,会遍历available Buffer,来判断元素是否已经就绪。
消费者读数据生产者多线程写入的情况下读数据会复杂很多:申请读取到序号n;若writer cursor >= n,这时仍然无法确定连续可读的最大下标。从reader cursor开始读取available Buffer,一直查到第一个不可用的元素,然后返回最大连续可读元素的位置;消费者读取元素。如下图所示,读线程读到下标为2的元素,三个线程Writer1/Writer2/Writer3正在向RingBuffer相应位置写数据,写线程被分配到的最大元素下标是11。读线程申请读取到下标从3到11的元素,判断writer cursor>=11。然后开始读取availableBuffer,从3开始,往后读取,发现下标为7的元素没有生产成功,于是WaitFor(11)返回6。然后,消费者读取下标从3到6共计4个元素。
多个生产者写数据多个生产者写入的时候:申请写入m个元素;若是有m个元素可以写入,则返回最大的序列号。每个生产者会被分配一段独享的空间;生产者写入元素,写入元素的同时设置available Buffer里面相应的位置,以标记自己哪些位置是已经写入成功的。如下图所示,Writer1和Writer2两个线程写入数组,都申请可写的数组空间。Writer1被分配了下标3到下表5的空间,Writer2被分配了下标6到下标9的空间。Writer1写入下标3位置的元素,同时把available Buffer相应位置置位,标记已经写入成功,往后移一位,开始写下标4位置的元素。Writer2同样的方式。最终都写入完成。
Disruptor构造器EventFactory:创建事件(任务)的工厂类。ringBufferSize:容器的长度。ThreadFactory :用于创建执行任务的线程。ProductType:生产者类型:单生产者、多生产者。WaitStrategy:等待策略。
Disruptor核心概念RingBuffer(环形缓冲区):基于数组的内存级别缓存,是创建sequencer(序号)与定义WaitStrategy(拒绝策略)的入口。Disruptor(总体执行入口):对RingBuffer的封装,持有RingBuffer、消费者线程池Executor、消费之集合ConsumerRepository等引用。Sequence(序号分配器):对RingBuffer中的元素进行序号标记,通过顺序递增的方式来管理进行交换的数据(事件/Event),一个Sequence可以跟踪标识某个事件的处理进度,同时还能消除伪共享。Sequencer(数据传输器):Sequencer里面包含了Sequence,是Disruptor的核心,Sequencer有两个实现类:SingleProducerSequencer(单生产者实现)、MultiProducerSequencer(多生产者实现),Sequencer主要作用是实现生产者和消费者之间快速、正确传递数据的并发算法SequenceBarrier(消费者屏障):用于控制RingBuffer的Producer和Consumer之间的平衡关系,并且决定了Consumer是否还有可处理的事件的逻辑。WaitStrategy(消费者等待策略):决定了消费者如何等待生产者将Event生产进Disruptor,WaitStrategy有多种实现策略Event:从生产者到消费者过程中所处理的数据单元,Event由使用者自定义。EventHandler:由用户自定义实现,就是我们写消费者逻辑的地方,代表了Disruptor中的一个消费者的接口。EventProcessor:这是个事件处理器接口,实现了Runnable,处理主要事件循环,处理Event,拥有消费者的Sequence
Disruptor
Runnable 的缺陷:不能返回一个返回值不能抛出 checked ExceptionCallable的call方法可以有返回值,可以声明抛出异常。和 Callable 配合的有一个 Future 类,通过 Future 可以了解任务执行情况,或者取消任务的执行,还可获取任务执行的结果,这些功能都是 Runnable 做不到的,Callable 的功能要比 Runnable 强大。
Callable&Future&FutureTask介绍直接继承Thread或者实现Runnable接口都可以创建线程,但是这两种方法都有一个问题就是:没有返回值,也就是不能获取执行完的结果。因此java1.5就提供了Callable接口来实现这一场景,而Future和FutureTask就可以和Callable接口配合起来使用。
利用 FutureTask 创建 FutureFuture实际采用FutureTask实现,该对象相当于是消费者和生产者的桥梁,消费者通过 FutureTask 存储任务的处理结果,更新任务的状态:未开始、正在处理、已完成等。而生产者拿到的 FutureTask 被转型为 Future 接口,可以阻塞式获取任务的处理结果,非阻塞式获取任务处理状态。FutureTask既可以被当做Runnable来执行,也可以被当做Future来获取Callable的返回结果。
如何使用把 Callable 实例当作 FutureTask 构造函数的参数,生成 FutureTask 的对象,然后把这个对象当作一个 Runnable 对象,放到线程池中或另起线程去执行,最后还可以通过 FutureTask 获取任务执行的结果。
Future 注意事项当 for 循环批量获取 Future 的结果时容易 block,get 方法调用时应使用 timeout 限制Future 的生命周期不能后退。一旦完成了任务,它就永久停在了“已完成”的状态,不能从头再来
Future的局限性从本质上说,Future表示一个异步计算的结果。它提供了isDone()来检测计算是否已经完成,并且在计算结束后,可以通过get()方法来获取计算结果。在异步计算中,Future确实是个非常优秀的接口。但是,它的本身也确实存在着许多限制:并发执行多任务:Future只提供了get()方法来获取结果,并且是阻塞的。所以,除了等待你别无他法;无法对多个任务进行链式调用:如果你希望在计算任务完成后执行特定动作,比如发邮件,但Future却没有提供这样的能力;无法组合多个任务:如果你运行了10个任务,并期望在它们全部执行结束后执行特定动作,那么在Future中这是无能为力的;没有异常处理:Future接口中没有关于异常处理的方法;
CompletionService原理内部通过阻塞队列+FutureTask,实现了任务先完成可优先获取到,即结果按照完成先后顺序排序,内部有一个先进先出的阻塞队列,用于保存已经执行完成的Future,通过调用它的take方法或poll方法可以获取到一个已经执行完成的Future,进而通过调用Future接口实现类的get方法获取最终的结果
应用场景总结当需要批量提交异步任务的时候建议你使用CompletionService。CompletionService将线程池Executor和阻塞队列BlockingQueue的功能融合在了一起,能够让批量异步任务的管理更简单。CompletionService能够让异步任务的执行结果有序化。先执行完的先进入阻塞队列,利用这个特性,你可以轻松实现后续处理的有序性,避免无谓的等待,同时还可以快速实现诸如Forking Cluster这样的需求。线程池隔离。CompletionService支持自己创建线程池,这种隔离性能避免几个特别耗时的任务拖垮整个应用的风险。
应用场景描述依赖关系:thenApply() 把前面异步任务的结果,交给后面的FunctionthenCompose()用来连接两个有依赖关系的任务,结果由第二个任务返回描述and聚合关系:thenCombine:任务合并,有返回值thenAccepetBoth:两个任务执行完成后,将结果交给thenAccepetBoth消耗,无返回值。runAfterBoth:两个任务都执行完成后,执行下一步操作(Runnable)。描述or聚合关系:applyToEither:两个任务谁执行的快,就使用那一个结果,有返回值。acceptEither: 两个任务谁执行的快,就消耗那一个结果,无返回值。runAfterEither: 任意一个任务执行完成,进行下一步操作(Runnable)。并行执行:CompletableFuture类自己也提供了anyOf()和allOf()用于支持多个CompletableFuture并行执行
CompletableFuture使用详解简单的任务,用Future获取结果还好,但我们并行提交的多个异步任务,往往并不是独立的,很多时候业务逻辑处理存在串行[依赖]、并行、聚合的关系。如果要我们手动用 Fueture 实现,是非常麻烦的。CompletableFuture是Future接口的扩展和增强。CompletableFuture实现了Future接口,并在此基础上进行了丰富地扩展,完美地弥补了Future上述的种种问题。更为重要的是,CompletableFuture实现了对任务的编排能力。借助这项能力,我们可以轻松地组织不同任务的运行顺序、规则以及方式。从某种程度上说,这项能力是它的核心能力。而在以往,虽然通过CountDownLatch等工具类也可以实现任务的编排,但需要复杂的逻辑处理,不仅耗费精力且难以维护。
CompletableFuture常用方法总结
Future
线程池
synchronized是JVM内置锁,基于Monitor机制实现,依赖底层操作系统的互斥原语Mutex(互斥量),它是一个重量级锁,性能较低。当然,JVM内置锁在1.5之后版本做了重大的 优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、自适应自旋(Adaptive Spinning)等技术来减少锁操 作的开销,内置锁的并发性能已经基本与Lock持平。
同步方法是通过方法中的access_flags中设置ACC_SYNCHRONIZED标志来实现;同步代码 块是通过monitorenter和monitorexit来实现。两个指令的执行是JVM通过调用操作系统的互斥 原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态 之间来回切换,对性能有较大影响。
Monitor(管程/监视器) Monitor,直译为“监视器”,而操作系统领域一般翻译为“管程”。管程是指管理共享变量以及对共享变量操作的过程,让它们支持并发。在Java 1.5之前,Java语言提供的唯一并发语言 就是管程,Java 1.5之后提供的SDK并发包也是以管程为基础的。除了Java之外,C/C++、C#等 高级语言也都是支持管程的。synchronized关键字和wait()、notify()、notifyAll()这三个方法是 Java中实现管程技术的组成部分。
Monitor机制在Java中的实现java.lang.Object 类定义了 wait(),notify(),notifyAll() 方法,这些方法的具体实现,依赖于 ObjectMonitor 实现,这是 JVM 内部基于 C++ 实现的一套机制。ObjectMonitor其主要数据结构如下(hotspot源码ObjectMonitor.hpp):在获取锁时,是将当前线程插入到cxq的头部,而释放锁时,默认策略(QMode=0)是:如果EntryList为空,则将cxq中的元素按原有顺序插入到EntryList,并唤醒第一个线程,也就是当EntryList为空时,是后来的线程先获取锁。_EntryList不为空,直接从_EntryList中唤醒线程。
MESA模型 在管程的发展史上,先后出现过三种不同的管程模型,分别是Hasen模型、Hoare模型和MESA模型。现在正在广泛使用的是MESA模型。下面我们便介绍MESA模型:管程中引入了条件变量的概念,而且每个条件变量都对应有一个等待队列。条件变量和等待队列的作用是解决线程之间的同步问题。
对象的内存布局Hotspot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。对象头:比如 hash码,对象所属的年代,对象锁,锁状态标志,偏向锁(线程)ID,偏向时间,数组长度(数组对象才有)等。实例数据:存放类的属性数据信息,包括父类的属性信息;对齐填充:由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。
32位JVM下的对象结构描述
64位JVM下的对象结构描述
偏向锁延迟偏向偏向锁模式存在偏向锁延迟机制:HotSpot 虚拟机在启动后有个 4s 的延迟才会对每个新建的对象开启偏向锁模式。JVM启动时会进行一系列的复杂活动,比如装载配置,系统类初始化等等。在这个过程中会使用大量synchronized关键字对对象加锁,且这些锁大多数都不是偏向锁。为了减少初始化时间,JVM默认延时加载偏向锁。
偏向锁撤销之调用对象HashCode调用锁对象的obj.hashCode()或System.identityHashCode(obj)方法会导致该对象的偏向锁被撤销。因为对于一个对象,其HashCode只会生成一次并保存,偏向锁是没有地方保存hashcode的。轻量级锁会在锁记录中记录 hashCode 重量级锁会在 Monitor 中记录 hashCode当对象处于可偏向(也就是线程ID为0)和已偏向的状态下,调用HashCode计算将会使对象再也无法偏向:当对象可偏向时,MarkWord将变成未锁定状态,并只能升级成轻量锁;当对象正处于偏向锁时,调用HashCode将使偏向锁强制升级成重量锁。
偏向锁撤销之调用wait/notify 偏向锁状态执行obj.notify() 会升级为轻量级锁,调用obj.wait(timeout) 会升级为重量级锁
偏向锁偏向锁是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了消除数据在无竞争情况下锁重入(CAS操作)的开销而引入偏向锁。对于没有锁竞争的场合,偏向锁有很好的优化效果。当JVM启用了偏向锁模式(jdk6默认开启),新创建对象的Mark Word中的Thread Id为0,说明此时处于可偏向但未偏向任何线程,也叫做匿名偏向状态(anonymously biased)。
轻量级锁倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段,此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间多个线程访问同一把锁的场合,就会导致轻量级锁膨胀为重量级锁。轻量级锁跟踪
测试:利用JOL工具跟踪锁标记变化
自旋优化重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。 在 Java 6 之后自旋是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,比较智能。 Java 7 之后不能控制是否开启自旋功能注意:自旋的目的是为了减少线程挂起的次数,尽量避免直接挂起线程(挂起操作涉及系统调用,存在用户态和内核态切换,这才是重量级锁最大的开销)
锁粗化假设一系列的连续操作都会对同一个对象反复加锁及解锁,甚至加锁操作是出现在循环体中的,即使没有出现线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。如果JVM检测到有一连串零碎的操作都是对同一对象的加锁,将会扩大加锁同步的范围(即锁粗化)到整个操作序列的外部。StringBuffer buffer = new StringBuffer();每次调用 buffer.append 方法都需要加锁和解锁,如果JVM检测到有一连串的对同一个对象加锁和解锁的操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append方法时进行加锁,最后一次append方法结束后进行解锁。
锁消除锁消除即删除不必要的加锁操作。锁消除是Java虚拟机在JIT编译期间,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过锁消除,可以节省毫无意义的请求锁时间。StringBuffer的append是个同步方法,但是append方法中的 StringBuffer 属于一个局部变量,不可能从该方法中逃逸出去,因此其实这过程是线程安全的,可以将锁消除。测试结果: 关闭锁消除执行时间4688 ms 开启锁消除执行时间:2601 ms
逃逸分析(Escape Analysis)逃逸分析,是一种可以有效减少Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。逃逸分析的基本行为就是分析对象动态作用域。方法逃逸(对象逃出当前方法)当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中。线程逃逸((对象逃出当前线程) 这个对象甚至可能被其它线程访问到,例如赋值给类变量或可以在其它线程中访问的实例变量。使用逃逸分析,编译器可以对代码做如下优化:1.同步省略或锁消除(Synchronization Elimination)。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。2.将堆分配转化为栈分配(Stack Allocation)。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。3.分离对象或标量替换(Scalar Replacement)。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
锁升级流程图
Synchronized轻量级锁源码分析
Synchronized重量级锁加锁解锁执行逻辑
synchronized
并发编程
B-Tree•叶节点具有相同的深度,叶节点的指针为空•所有索引元素不重复•节点中的数据索引从左到右递增排列
B+Tree(B-Tree变种)•非叶子节点不存储data,只存储索引(冗余),可以放更多的索引•叶子节点包含所有索引字段•叶子节点用指针连接,提高区间访问的性能
Hash•对索引的key进行一次hash计算就可以定位出数据存储的位置•很多时候Hash索引要比B+ 树索引更高效•仅能满足 “=”,“IN”,不支持范围查询•hash冲突问题
•MyISAM索引文件和数据文件是分离的(非聚集)
聚集索引
非聚集索引
InnoDB索引实现(聚集)•表数据文件本身就是按B+Tree组织的一个索引结构文件•聚集索引-叶节点包含了完整的数据记录•为什么建议InnoDB表必须建主键,并且推荐使用整型的自增主键?•为什么非主键索引结构叶子节点存储的是主键值?(一致性和节省存储空间)
联合索引的底层存储结构
总结:MySQL有哪些索引类型从数据结构角度可分为B+树索引、哈希索引、以及FULLTEXT索引(现在MyISAM和InnoDB引擎都支持了)和R-Tree索引(用于对GIS数据类型创建SPATIAL索引);从物理存储角度可分为聚集索引(clustered index)、非聚集索引(non-clustered index);从逻辑角度可分为主键索引、普通索引,或者单列索引、多列索引、唯一索引、非唯一索引等等。
面试题:什么是密集索引和稀疏索引?面试中还会被问到什么是密集索引和稀疏索引。密集索引的定义:叶子节点保存的不只是键值,还保存了位于同一行记录里的其他列的信息,由于密集索引决定了表的物理排列顺序,一个表只有一个物理排列顺序,所以一个表只能创建一个密集索引。稀疏索引:叶子节点仅保存了键位信息以及该行数据的地址,有的稀疏索引只保存了键位信息机器主键。mysam存储引擎,不管是主键索引,唯一键索引还是普通索引都是稀疏索引,innodb存储引擎:有且只有一个密集索引。所以,密集索引就是innodb存储引擎里的聚簇索引,稀疏索引就是innodb存储引擎里的普通二级索引。
索引是帮助MySQL高效获取数据的排好序的数据结构索引数据结构•二叉树•红黑树•Hash表•B-Tree
子主题
索引
Explain工具介绍 使用EXPLAIN关键字可以模拟优化器执行SQL语句,分析你的查询语句或是结构的性能瓶颈在 select 语句之前增加 explain 关键字,MySQL 会在查询上设置一个标记,执行查询会返回执行计划的信息,而不是 执行这条SQL注意:如果 from 中包含子查询,仍会执行该子查询,将结果放入临时表中
1 mysql> explain extended select * from film where id = 1;
1 mysql> show warnings;
1)explain extended:会在 explain 的基础上额外提供一些查询优化的信息。紧随其后通过 show warnings 命令可以得到优化后的查询语句,从而看出优化器优化了什么。额外还有 filtered 列,是一个半分比的值,rows * filtered/100 可以估算出将要和 explain 中前一个表进行连接的行数(前一个表指 explain 中的id值比当前表id值小的 表)。
2)explain partitions:相比 explain 多了个 partitions 字段,如果查询是基于分区表的话,会显示查询将访问的分区。
explain 两个变种
1. id列id列的编号是 select 的序列号,有几个 select 就有几个id,并且id的顺序是按 select 出现的顺序增长的。 id列越大执行优先级越高,id相同则从上往下执行,id为NULL最后执行。
1)simple:简单查询。查询不包含子查询和union1 mysql> explain select * from film where id = 2;
2)primary:复杂查询中最外层的 select
3)subquery:包含在 select 中的子查询(不在 from 子句中)
4)derived:包含在 from 子句中的子查询。MySQL会将结果存放在一个临时表中,也称为派生表(derived的英文含 义)
用这个例子来了解 primary、subquery 和 derived 类型mysql> set session optimizer_switch='derived_merge=off'; #关闭mysql5.7新特性对衍生表的合 并优化mysql> explain select (select 1 from actor where id = 1) from (select * from film wher id = 1) der;
5)union:在 union 中的第二个和随后的 select1 mysql> explain select 1 union all select 1;
2. select_type列select_type 表示对应行是简单还是复杂的查询。
NULL:mysql能够在优化阶段分解查询语句,在执行阶段用不着再访问表或索引。例如:在索引列中选取最小值,可 以单独查找索引来完成,不需要在执行时访问表1 mysql> explain select min(id) from film;
1 mysql> explain extended select * from (select * from film where id = 1) tmp;
eq_ref:primary key 或 unique key 索引的所有部分被连接使用 ,最多只会返回一条符合条件的记录。这可能是在 const 之外最好的联接类型了,简单的 select 查询不会出现这种 type。mysql> explain select * from film_actor left join film on film_actor.film_id = film.id;
1. 简单 select 查询,name是普通索引(非唯一索引)1 mysql> explain select * from film where name = 'film1';
2.关联表查询,idx_film_actor_id是film_id和actor_id的联合索引,这里使用到了film_actor的左边前缀film_id部分。mysql> explain select film_id from film left join film_actor on film.id = film_actor.f lm_id;
ref:相比 eq_ref,不使用唯一索引,而是使用普通索引或者唯一性索引的部分前缀,索引要和某个值相比较,可能会 找到多个符合条件的行。
index:扫描全索引就能拿到结果,一般是扫描某个二级索引,这种扫描不会从索引树根节点开始快速查找,而是直接 对二级索引的叶子节点遍历和扫描,速度还是比较慢的,这种查询一般为使用覆盖索引,二级索引一般比较小,所以这 种通常比ALL快一些。1 mysql> explain select * from film;
ALL:即全表扫描,扫描你的聚簇索引的所有叶子节点。通常情况下这需要增加索引来进行优化了。1 mysql> explain select * from actor;
4. type列这一列表示关联类型或访问类型,即MySQL决定如何查找表中的行,查找数据行记录的大概范围。 依次从最优到最差分别为:system > const > eq_ref > ref > range > index > ALL 一般来说,得保证查询达到range级别,最好达到ref
5. possible_keys列这一列显示查询可能使用哪些索引来查找。explain 时可能出现 possible_keys 有列,而 key 显示 NULL 的情况,这种情况是因为表中数据不多,mysql认为索引 对此查询帮助不大,选择了全表查询。如果该列是NULL,则没有相关的索引。在这种情况下,可以通过检查 where 子句看是否可以创造一个适当的索引来提 高查询性能,然后用 explain 查看效果。
6. key列这一列显示mysql实际采用哪个索引来优化对该表的访问。如果没有使用索引,则该列是 NULL。如果想强制mysql使用或忽视possible_keys列中的索引,在查询中使用 force index、ignore index。
key_len计算规则如下: 字符串,char(n)和varchar(n),5.0.3以后版本中,n均代表字符数,而不是字节数,如果是utf-8,一个数字或字母占1个字节,一个汉字占3个字节 char(n):如果存汉字长度就是 3n 字节varchar(n):如果存汉字则长度是 3n + 2 字节,加的2字节用来存储字符串长度,因为 varchar是变长字符串数值类型 tinyint:1字节smallint:2字节 int:4字节 bigint:8字节 时间类型 date:3字节timestamp:4字节datetime:8字节如果字段允许为 NULL,需要1字节记录是否为 NULL索引最大长度是768字节,当字符串过长时,mysql会做一个类似左前缀索引的处理,将前半部分的字符提取出来做索 引。
7. key_len列这一列显示了mysql在索引里使用的字节数,通过这个值可以算出具体使用了索引中的哪些列。 举例来说,film_actor的联合索引 idx_film_actor_id 由 film_id 和 actor_id 两个int列组成,并且每个int是4字节。通 过结果中的key_len=4可推断出查询使用了第一个列:film_id列来执行索引查找。1 mysql> explain select * from film_actor where film_id = 2;
8. ref列 这一列显示了在key列记录的索引中,表查找值所用到的列或常量,常见的有:const(常量),字段名(例:film.id)9. rows列 这一列是mysql估计要读取并检测的行数,注意这个不是结果集里的行数。
1)Using index:使用覆盖索引 覆盖索引定义:mysql执行计划explain结果里的key有使用索引,如果select后面查询的字段都可以从这个索引的树中 获取,这种情况一般可以说是用到了覆盖索引,extra里一般都有using index;覆盖索引一般针对的是辅助索引,整个 查询结果只通过辅助索引就能拿到结果,不需要通过辅助索引树找到主键,再通过主键去主键索引树里获取其它字段值1 mysql> explain select film_id from film_actor where film_id = 1;
2)Using where:使用 where 语句来处理结果,并且查询的列未被索引覆盖1 mysql> explain select * from actor where name = 'a';
3)Using index condition:查询的列不完全被索引覆盖,where条件中是一个前导列的范围;1 mysql> explain select * from film_actor where film_id > 1;
1. actor.name没有索引,此时创建了张临时表来distinct1 mysql> explain select distinct name from actor;
4)Using temporary:mysql需要创建一张临时表来处理查询。出现这种情况一般是要进行优化的,首先是想到用索 引来优化。
1. actor.name未创建索引,会浏览actor整个表,保存排序关键字name和对应的id,然后排序name并检索行记录1 mysql> explain select * from actor order by name;
5)Using filesort:将用外部排序而不是索引排序,数据较小时从内存排序,否则需要在磁盘完成排序。这种情况下一 般也是要考虑使用索引来优化的。
6)Select tables optimized away:使用某些聚合函数(比如 max、min)来访问存在索引的某个字段是1 mysql> explain select min(id) from film;
10. Extra列这一列展示的是额外信息。常见的重要值如下:
explain中的列接下来我们将展示 explain 中每个列的信息。
Explain
1 EXPLAIN SELECT * FROM employees WHERE name= 'LiLei';
1 EXPLAIN SELECT * FROM employees WHERE name= 'LiLei' AND age = 22;
1 EXPLAIN SELECT * FROM employees WHERE name= 'LiLei' AND age = 22 AND position ='manager';
1.全值匹配
2.最左前缀法则 如果索引了多列,要遵守最左前缀法则。指的是查询从索引的最左前列开始并且不跳过索引中的列。EXPLAIN SELECT * FROM employees WHERE name = 'Bill' and age = 31;2 EXPLAIN SELECT * FROM employees WHERE age = 30 AND position = 'dev'; 3 EXPLAIN SELECT * FROM employees WHERE position = 'manager';
给hire_time增加一个普通索引:1 ALTER TABLE `employees` ADD INDEX `idx_hire_time` (`hire_time`) USING BTREE ; 1 EXPLAIN select * from employees where date(hire_time) ='2018‐09‐30';
转化为日期范围查询,有可能会走索引:1 EXPLAIN select * from employees where hire_time >='2018‐09‐30 00:00:00' and hire_time ='2018‐09‐30 23:59:59';还原最初索引状态1 ALTER TABLE `employees` DROP INDEX `idx_hire_time`;
4.存储引擎不能使用索引中范围条件右边的列1 EXPLAIN SELECT * FROM employees WHERE name= 'LiLei' AND age = 22 AND position ='manage r';2 EXPLAIN SELECT * FROM employees WHERE name= 'LiLei' AND age > 22 AND position ='manage r';
EXPLAIN SELECT * FROM employees WHERE name= 'LiLei' AND age = 23 AND position ='manage r';
5.尽量使用覆盖索引(只访问索引的查询(索引列包含查询列)),减少 select * 语句
6.mysql在使用不等于(!=或者<>),not in ,not exists 的时候无法使用索引会导致全表扫描< 小于、 > 大于、 <=、>= 这些,mysql内部优化器会根据检索比例、表大小等多个因素整体评估是否使用索引1 EXPLAIN SELECT * FROM employees WHERE name != 'LiLei';
1 EXPLAIN SELECT * FROM employees WHERE name like '%Lei'
1 EXPLAIN SELECT * FROM employees WHERE name like 'Lei%'
b)如果不能使用覆盖索引则可能需要借助搜索引擎
8.like以通配符开头('$abc...')mysql索引失效会变成全表扫描操作问题:解决like'%字符串%'索引不被使用的方法?
9.字符串不加单引号索引失效1 EXPLAIN SELECT * FROM employees WHERE name = '1000'; 2 EXPLAIN SELECT * FROM employees WHERE name = 1000;
10.少用or或in,用它查询时,mysql不一定使用索引,mysql内部优化器会根据检索比例、表大小等多个因素整体评 估是否使用索引,详见范围查询优化1 EXPLAIN SELECT * FROM employees WHERE name = 'LiLei' or name = 'HanMeimei';
给年龄添加单值索引1 ALTER TABLE `employees` ADD INDEX `idx_age` (`age`) USING BTREE ; 1 explain select * from employees where age >=1 and age <=2000;没走索引原因:mysql内部优化器会根据检索比例、表大小等多个因素整体评估是否使用索引。比如这个例子,可能是 由于单次数据量查询过大导致优化器最终选择不走索引
优化方法:可以将大的范围拆分成多个小范围1 explain select * from employees where age >=1 and age <=1000;2 explain select * from employees where age >=1001 and age <=2000;还原最初索引状态1 ALTER TABLE `employees` DROP INDEX `idx_age`;
11.范围查询优化
索引最佳实践
1、联合索引第一个字段用范围不会走索引EXPLAIN SELECT * FROM employees WHERE name > 'LiLei' AND age = 22 AND position ='manager';结论:联合索引第一个字段就用范围查找不会走索引,mysql内部可能觉得第一个字段就用范围,结果集应该很大,回表效率不高,还不如就全表扫描
2、强制走索引EXPLAIN SELECT * FROM employees force index(idx_name_age_position) WHERE name > 'LiLei' AND age = 22 AND position ='manager';结论:虽然使用了强制走索引让联合索引第一个字段范围查找也走索引,扫描的行rows看上去也少了点,但是最终查找效率不一定比全表扫描高,因为回表效率不高-- 关闭查询缓存set global query_cache_size=0; set global query_cache_type=0;-- 执行时间0.333sSELECT * FROM employees WHERE name > 'LiLei';-- 执行时间0.444sSELECT * FROM employees force index(idx_name_age_position) WHERE name > 'LiLei';
为什么范围查找Mysql没有用索引下推优化?估计应该是Mysql认为范围查找过滤的结果集过大,like KK% 在绝大多数情况来看,过滤后的结果集比较小,所以这里Mysql选择给 like KK% 用了索引下推优化,当然这也不是绝对的,有时like KK% 也不一定就会走索引下推。
分析:利用最左前缀法则:中间字段不能断,因此查询用到了name索引,从key_len=74也能看出,age索引列用在排序过程中,因为Extra字段里没有using filesort
从explain的执行结果来看:key_len=74,查询使用了name索引,由于用了position进行排序,跳过了age,出现了Using filesort。
查找只用到索引name,age和position用于排序,无Using filesort。
与Case 4对比,在Extra中并未出现Using filesort,因为age为常量,在排序中被优化,所以索引未颠倒,不会出现Using filesort。
虽然排序的字段列与索引顺序一样,且order by默认升序,这里position desc变成了降序,导致与索引的排序方式不同,从而产生Using filesort。Mysql8以上版本有降序索引可以支持该种查询方式。
对于排序来说,多个相等条件也是范围查询
可以用覆盖索引优化
常见sql深入优化Order by与Group by优化
优化总结:1、MySQL支持两种方式的排序filesort和index,Using index是指MySQL扫描索引本身完成排序。index效率高,filesort效率低。2、order by满足两种情况会使用Using index。 1) order by语句使用索引最左前列。 2) 使用where子句与order by子句条件列组合满足索引最左前列。3、尽量在索引列上完成排序,遵循索引建立(索引创建的顺序)时的最左前缀法则。4、如果order by的条件不在索引列上,就会产生Using filesort。5、能用覆盖索引尽量用覆盖索引6、group by与order by很类似,其实质是先排序后分组,遵照索引创建顺序的最左前缀法则。对于group by的优化如果不需要排序的可以加上order by null禁止排序。注意,where高于having,能写在where中的限定条件就不要去having限定了。
mysql> set session optimizer_trace=\"enabled=on\
我们先看单路排序的详细过程:从索引name找到第一个满足 name = ‘zhuge’ 条件的主键 id根据主键 id 取出整行,取出所有字段的值,存入 sort_buffer 中从索引name找到下一个满足 name = ‘zhuge’ 条件的主键 id重复步骤 2、3 直到不满足 name = ‘zhuge’ 对 sort_buffer 中的数据按照字段 position 进行排序返回结果给客户端我们再看下双路排序的详细过程:从索引 name 找到第一个满足 name = ‘zhuge’ 的主键id根据主键 id 取出整行,把排序字段 position 和主键 id 这两个字段放到 sort buffer 中从索引 name 取下一个满足 name = ‘zhuge’ 记录的主键 id重复 3、4 直到不满足 name = ‘zhuge’ 对 sort_buffer 中的字段 position 和主键 id 按照字段 position 进行排序遍历排序好的 id 和字段 position,按照 id 的值回到原表中取出 所有字段的值返回给客户端其实对比两个排序模式,单路排序会把所有需要查询的字段都放到 sort buffer 中,而双路排序只会把主键和需要排序的字段放到 sort buffer 中进行排序,然后再通过主键回到原表查询需要的字段。如果 MySQL 排序内存 sort_buffer 配置的比较小并且没有条件继续增加了,可以适当把 max_length_for_sort_data 配置小点,让优化器选择使用双路排序算法,可以在sort_buffer 中一次排序更多的行,只是需要再根据主键回到原表取数据。如果 MySQL 排序内存有条件可以配置比较大,可以适当增大 max_length_for_sort_data 的值,让优化器优先选择全字段排序(单路排序),把需要的字段放到 sort_buffer 中,这样排序后就会直接从内存里返回查询结果了。所以,MySQL通过 max_length_for_sort_data 这个参数来控制排序,在不同场景使用不同的排序模式,从而提升排序效率。注意,如果全部使用sort_buffer内存排序一般情况下效率会高于磁盘文件排序,但不能因为这个就随便增大sort_buffer(默认1M),mysql很多参数设置都是做过优化的,不要轻易调整。
索引优化实践
mysql> EXPLAIN select * from employees where id > 90000 limit 5;显然改写后的 SQL 走了索引,而且扫描的行数大大减少,执行效率更高。但是,这条改写的SQL 在很多场景并不实用,因为表中可能某些记录被删后,主键空缺,导致结果不一致,如下图试验所示(先删除一条前面的记录,然后再测试原 SQL 和优化后的 SQL):另外如果原 SQL 是 order by 非主键的字段,按照上面说的方法改写会导致两条 SQL 的结果不一致。所以这种改写得满足以下两个条件:主键自增且连续结果是按照主键排序的
1、根据自增且连续的主键排序的分页查询
分页查询优化
上面sql的大致流程如下:从表 t2 中读取一行数据(如果t2表有查询过滤条件的,用先用条件过滤完,再从过滤结果里取出一行数据);从第 1 步的数据中,取出关联字段 a,到表 t1 中查找;取出表 t1 中满足条件的行,跟 t2 中获取到的结果合并,作为结果返回给客户端;重复上面 3 步。整个过程会读取 t2 表的所有数据(扫描100行),然后遍历这每行数据中字段 a 的值,根据 t2 表中 a 的值索引扫描 t1 表中的对应行(扫描100次 t1 表的索引,1次扫描可以认为最终只扫描 t1 表一行完整数据,也就是总共 t1 表也扫描了100行)。因此整个过程扫描了 200 行。如果被驱动表的关联字段没索引,使用NLJ算法性能会比较低(下面有详细解释),mysql会选择Block Nested-Loop Join算法。
1、 嵌套循环连接 Nested-Loop Join(NLJ) 算法一次一行循环地从第一张表(称为驱动表)中读取行,在这行数据中取到关联字段,根据关联字段在另一张表(被驱动表)里取出满足条件的行,然后取出两张表的结果合集。mysql> EXPLAIN select * from t1 inner join t2 on t1.a= t2.a;从执行计划中可以看到这些信息:驱动表是 t2,被驱动表是 t1。先执行的就是驱动表(执行计划结果的id如果一样则按从上到下顺序执行sql);优化器一般会优先选择小表做驱动表,用where条件过滤完驱动表,然后再跟被驱动表做关联查询。所以使用 inner join 时,排在前面的表并不一定就是驱动表。当使用left join时,左表是驱动表,右表是被驱动表,当使用right join时,右表时驱动表,左表是被驱动表,当使用join时,mysql会选择数据量比较小的表作为驱动表,大表作为被驱动表。使用了 NLJ算法。一般 join 语句中,如果执行计划 Extra 中未出现 Using join buffer 则表示使用的 join 算法是 NLJ。
上面sql的大致流程如下:把 t2 的所有数据放入到 join_buffer 中把表 t1 中每一行取出来,跟 join_buffer 中的数据做对比返回满足 join 条件的数据整个过程对表 t1 和 t2 都做了一次全表扫描,因此扫描的总行数为10000(表 t1 的数据总量) + 100(表 t2 的数据总量) = 10100。并且 join_buffer 里的数据是无序的,因此对表 t1 中的每一行,都要做 100 次判断,所以内存中的判断次数是 100 * 10000= 100 万次。这个例子里表 t2 才 100 行,要是表 t2 是一个大表,join_buffer 放不下怎么办呢?·join_buffer 的大小是由参数 join_buffer_size 设定的,默认值是 256k。如果放不下表 t2 的所有数据话,策略很简单,就是分段放。比如 t2 表有1000行记录, join_buffer 一次只能放800行数据,那么执行过程就是先往 join_buffer 里放800行记录,然后从 t1 表里取数据跟 join_buffer 中数据对比得到部分结果,然后清空 join_buffer ,再放入 t2 表剩余200行记录,再次从 t1 表里取数据跟 join_buffer 中数据对比。所以就多扫了一次 t1 表。
2、 基于块的嵌套循环连接 Block Nested-Loop Join(BNL)算法把驱动表的数据读入到 join_buffer 中,然后扫描被驱动表,把被驱动表每一行取出来跟 join_buffer 中的数据做对比。mysql>EXPLAIN select * from t1 inner join t2 on t1.b= t2.b;Extra 中 的Using join buffer (Block Nested Loop)说明该关联查询使用的是 BNL 算法。
被驱动表的关联字段没索引为什么要选择使用 BNL 算法而不使用 Nested-Loop Join 呢?如果上面第二条sql使用 Nested-Loop Join,那么扫描行数为 100 * 10000 = 100万次,这个是磁盘扫描。很显然,用BNL磁盘扫描次数少很多,相比于磁盘扫描,BNL的内存计算会快得多。因此MySQL对于被驱动表的关联字段没索引的关联查询,一般都会使用 BNL 算法。如果有索引一般选择 NLJ 算法,有索引的情况下 NLJ 算法比 BNL算法性能更高
mysql的表关联常见有两种算法Nested-Loop Join 算法Block Nested-Loop Join 算法
对于关联sql的优化关联字段加索引,让mysql做join操作时尽量选择NLJ算法,驱动表因为需要全部查询出来,所以过滤的条件也尽量要走索引,避免全表扫描,总之,能走索引的过滤条件尽量都走索引小表驱动大表,写多表连接sql时如果明确知道哪张表是小表可以用straight_join写法固定连接驱动方式,省去mysql优化器自己判断的时间straight_join解释:straight_join功能同join类似,但能让左边的表来驱动右边的表,能改表优化器对于联表查询的执行顺序。比如:select * from t2 straight_join t1 on t2.a = t1.a; 代表指定mysql选着 t2 表作为驱动表。straight_join只适用于inner join,并不适用于left join,right join。(因为left join,right join已经代表指定了表的执行顺序)尽可能让优化器去判断,因为大部分情况下mysql优化器是比人要聪明的。使用straight_join一定要慎重,因为部分情况下人为指定的执行顺序并不一定会比优化引擎要靠谱。
对于小表定义的明确在决定哪个表做驱动表的时候,应该是两个表按照各自的条件过滤,过滤完成之后,计算参与 join 的各个字段的总数据量,数据量小的那个表,就是“小表”,应该作为驱动表。
in:当B表的数据集小于A表的数据集时,in优于exists select * from A where id in (select id from B) #等价于: for(select id from B){ select * from A where A.id = B.id }
in和exsits优化原则:小表驱动大表,即小的数据集驱动大的数据集
四个sql的执行计划一样,说明这四个sql执行效率应该差不多字段有索引:count(*)≈count(1)>count(字段)>count(主键 id) //字段有索引,count(字段)统计走二级索引,二级索引存储数据比主键索引少,所以count(字段)>count(主键 id) 字段无索引:count(*)≈count(1)>count(主键 id)>count(字段) //字段没有索引count(字段)统计走不了索引,count(主键 id)还可以走主键索引,所以count(主键 id)>count(字段)count(1)跟count(字段)执行过程类似,不过count(1)不需要取出字段统计,就用常量1做统计,count(字段)还需要取出字段,所以理论上count(1)比count(字段)会快一点。count(*) 是例外,mysql并不会把全部字段取出来,而是专门做了优化,不取值,按行累加,效率很高,所以不需要用count(列名)或count(常量)来替代 count(*)。为什么对于count(id),mysql最终选择辅助索引而不是主键聚集索引?因为二级索引相对主键索引存储数据更少,检索性能应该更高,mysql内部做了点优化(应该是在5.7版本才优化)。
count(*)查询优化-- 临时关闭mysql查询缓存,为了查看sql多次执行的真实时间mysql> set global query_cache_size=0;mysql> set global query_cache_type=0;mysql> EXPLAIN select count(1) from employees;mysql> EXPLAIN select count(id) from employees;mysql> EXPLAIN select count(name) from employees;mysql> EXPLAIN select count(*) from employees;注意:以上4条sql只有根据某个字段count不会统计字段为null值的数据行
1、查询mysql自己维护的总行数对于myisam存储引擎的表做不带where条件的count查询性能是很高的,因为myisam存储引擎的表的总行数会被mysql存储在磁盘上,查询不需要计算对于innodb存储引擎的表mysql不会存储表的总记录行数(因为有MVCC机制,后面会讲),查询count需要实时计算
2、show table status如果只需要知道表总行数的估计值可以用如下sql查询,性能很高
3、将总数维护到Redis里插入或删除表数据行的时候同时维护redis里的表总行数key的计数值(用incr或decr命令),但是这种方式可能不准,很难保证表操作和redis操作的事务一致性
4、增加数据库计数表插入或删除表数据行的时候同时维护计数表,让他们在同一个事务里操作
常见优化方法
Join关联查询优化
2.最左前缀法则 如果索引了多列,要遵守最左前缀法则。指的是查询从索引的最左前列开始并且不跳过索引中的列。
3.不在索引列上做任何操作(计算、函数、(自动or手动)类型转换),会导致索引失效而转向全表扫描
4.存储引擎不能使用索引中范围条件右边的列
6.mysql在使用不等于(!=或者<>),not in ,not exists 的时候无法使用索引会导致全表扫描
8.like以通配符开头('$abc...')mysql索引失效会变成全表扫描操作
9.字符串不加单引号索引失效
10.少用or或in,用它查询时,mysql不一定使用索引,mysql内部优化器会根据检索比例、表大小等多个因素整体评 估是否使用索引,详见范围查询优化
索引失效
索引实战
这就意味着,一个用户成功建立连接后,即使你用管理员账号对这个用户的权限做了修改,也不会影响已经存在连接的权限。修改完成后,只有再新建的连接才会使用新的权限设置。用户的权限表在系统表空间的mysql的user表中。
连接完成后,如果你没有后续的动作,这个连接就处于空闲状态,你可以在 show processlist 命令中看到它。文本中这个图是 show processlist 的结果,其中的 Command 列显示为“Sleep”的这一行,就表示现在系统里面有一个空闲连接,关闭连接 kill <id>。
客户端如果长时间不发送command到Server端,连接器就会自动将它断开。这个时间是由参数 wait_timeout 控制的,默认值是 8 小时。查看wait_timeoutmysql> show global variables like \"wait_timeout\";mysql>set global wait_timeout=28800; 设置全局服务器关闭非交互连接之前等待活动的秒数
大多数情况查询缓存就是个鸡肋,为什么呢?因为查询缓存往往弊大于利。查询缓存的失效非常频繁,只要有对一个表的更新,这个表上所有的查询缓存都会被清空。因此很可能你费劲地把结果存起来,还没使用呢,就被一个更新全清空了。对于更新压力大的数据库来说,查询缓存的命中率会非常低。一般建议大家在静态表里使用查询缓存,什么叫静态表呢?就是一般我们极少更新的表。比如,一个系统配置表、字典表,那这张表上的查询才适合使用查询缓存。好在 MySQL 也提供了这种“按需使用”的方式。你可以将my.cnf参数 query_cache_type 设置成 DEMAND。my.cnf#query_cache_type有3个值 0代表关闭查询缓存OFF,1代表开启ON,2(DEMAND)代表当sql语句中有SQL_CACHE关键词时才缓存query_cache_type=2这样对于默认的 SQL 语句都不使用查询缓存。而对于你确定要使用查询缓存的语句,可以用 SQL_CACHE 显式指定,像下面这个语句一样:mysql> select SQL_CACHE * from test where ID=5;查看当前mysql实例是否开启缓存机制mysql> show global variables like \"%query_cache_type%\";监控查询缓存的命中率:mysql> show status like'%Qcache%'; //查看运行的缓存信息
Qcache_free_blocks:表示查询缓存中目前还有多少剩余的blocks,如果该值显示较大,则说明查询缓存中的内存碎片过多了,可能在一定的时间进行整理。Qcache_free_memory:查询缓存的内存大小,通过这个参数可以很清晰的知道当前系统的查询内存是否够用,是多了,还是不够用,DBA可以根据实际情况做出调整。Qcache_hits:表示有多少次命中缓存。我们主要可以通过该值来验证我们的查询缓存的效果。数字越大,缓存效果越理想。Qcache_inserts: 表示多少次未命中然后插入,意思是新来的SQL请求在缓存中未找到,不得不执行查询处理,执行查询处理后把结果insert到查询缓存中。这样的情况的次数,次数越多,表示查询缓存应用到的比较少,效果也就不理想。当然系统刚启动后,查询缓存是空的,这很正常。Qcache_lowmem_prunes:该参数记录有多少条查询因为内存不足而被移除出查询缓存。通过这个值,用户可以适当的调整缓存大小。Qcache_not_cached: 表示因为query_cache_type的设置而没有被缓存的查询数量。Qcache_queries_in_cache:当前缓存中缓存的查询数量。Qcache_total_blocks:当前缓存的block数量。mysql8.0已经移除了查询缓存功能
查询缓存常用的一些操作mysql>show databases; 显示所有数据库mysql>use dbname; 打开数据库:mysql>show tables; 显示数据库mysql中所有的表;mysql>describe user; 显示表mysql数据库中user表的列信息);连接建立完成后,你就可以执行 select 语句了。执行逻辑就会来到第二步:查询缓存。MySQL 拿到一个查询请求后,会先到查询缓存看看,之前是不是执行过这条语句。之前执行过的语句及其结果可能会以 key-value 对的形式,被直接缓存在内存中。key 是查询的语句,value 是查询的结果。如果你的查询能够直接在这个缓存中找到 key,那么这个 value 就会被直接返回给客户端。如果语句不在查询缓存中,就会继续后面的执行阶段。执行完成后,执行结果会被存入查询缓存中。你可以看到,如果查询命中缓存,MySQL 不需要执行后面的复杂操作,就可以直接返回结果,这个效率会很高。大多数情况查询缓存就是个鸡肋,为什么呢?
分析器如果没有命中查询缓存,就要开始真正执行语句了。首先,MySQL 需要知道你要做什么,因此需要对 SQL 语句做解析。分析器先会做“词法分析”。你输入的是由多个字符串和空格组成的一条 SQL 语句,MySQL 需要识别出里面的字符串分别是什么,代表什么。MySQL 从你输入的\"select\"这个关键字识别出来,这是一个查询语句。它也要把字符串“T”识别成“表名 T”,把字符串“ID”识别成“列 ID”。做完了这些识别以后,就要做“语法分析”。根据词法分析的结果,语法分析器会根据语法规则,判断你输入的这个 SQL 语句是否满足 MySQL 语法。如果你的语句不对,就会收到“You have an error in your SQL syntax”的错误提醒,比如下面这个语句 from 写成了 \"rom\"。mysql> select * fro test where id=1;ERROR 1064 (42000): You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'fro test where id=1' at line 1
下图是SQL词法分析的过程步骤:SQL语句的分析分为词法分析与语法分析,mysql的词法分析由MySQLLex[MySQL自己实现的]完成,语法分析由Bison生成。关于语法树大家如果想要深入研究可以参考这篇wiki文章:https://en.wikipedia.org/wiki/LR_parser。那么除了Bison外,Java当中也有开源的词法结构分析工具例如Antlr4,ANTLR从语法生成一个解析器,可以构建和遍历解析树,可以在IDEA工具当中安装插件:antlr v4 grammar plugin。插件使用详见课程
经过bison语法分析之后,会生成一个这样的语法树
词法分析器原理词法分析器分成6个主要步骤完成对sql语句的分析1、词法分析2、语法分析3、语义分析4、构造执行树5、生成执行计划6、计划的执行
优化器经过了分析器,MySQL 就知道你要做什么了。在开始执行之前,还要先经过优化器的处理。优化器是在表里面有多个索引的时候,决定使用哪个索引;或者在一个语句有多表关联(join)的时候,决定各个表的连接顺序。比如你执行下面这样的语句,这个语句是执行两个表的 join:mysql> select * from test1 join test2 using(ID) where test1.name=yangguo and test2.name=xiaolongnv;既可以先从表 test1 里面取出 name=yangguo的记录的 ID 值,再根据 ID 值关联到表 test2,再判断 test2 里面 name的值是否等于 yangguo。也可以先从表 test2 里面取出 name=xiaolongnv 的记录的 ID 值,再根据 ID 值关联到 test1,再判断 test1 里面 name 的值是否等于 yangguo。这两种执行方法的逻辑结果是一样的,但是执行的效率会有不同,而优化器的作用就是决定选择使用哪一个方案。优化器阶段完成后,这个语句的执行方案就确定下来了,然后进入执行器阶段。如果你还有一些疑问,比如优化器是怎么选择索引的,有没有可能选择错等等。执行器
执行器开始执行的时候,要先判断一下你对这个表 T 有没有执行查询的权限,如果没有,就会返回没有权限的错误,如下所示 (在工程实现上,如果命中查询缓存,会在查询缓存返回结果的时候,做权限验证。查询也会在优化器之前调用 precheck 验证权限)。mysql> select * from test where id=1;如果有权限,就打开表继续执行。打开表的时候,执行器就会根据表的引擎定义,去使用这个引擎提供的接口。比如我们这个例子中的表 test 中,ID 字段没有索引,那么执行器的执行流程是这样的:调用 InnoDB 引擎接口取这个表的第一行,判断 ID 值是不是 10,如果不是则跳过,如果是则将这行存在结果集中;调用引擎接口取“下一行”,重复相同的判断逻辑,直到取到这个表的最后一行。执行器将上述遍历过程中所有满足条件的行组成的记录集作为结果集返回给客户端。至此,这个语句就执行完成了。对于有索引的表,执行的逻辑也差不多。第一次调用的是“取满足条件的第一行”这个接口,之后循环取“满足条件的下一行”这个接口,这些接口都是引擎中已经定义好的。你会在数据库的慢查询日志中看到一个 rows_examined 的字段,表示这个语句执行过程中扫描了多少行。这个值就是在执行器每次调用引擎获取数据行的时候累加的。在有些场景下,执行器调用一次,在引擎内部则扫描了多行,因此引擎扫描行数跟 rows_examined 并不是完全相同的。
binlog命令mysql> show variables like '%log_bin%'; 查看bin-log是否开启mysql> flush logs; 会多一个最新的bin-log日志mysql> show master status; 查看最后一个bin-log日志的相关信息mysql> reset master; 清空所有的bin-log日志
数据归档操作从bin-log恢复数据恢复全部数据/usr/local/mysql/bin/mysqlbinlog --no-defaults /usr/local/mysql/data/binlog/mysql-bin.000001 |mysql -uroot -p tuling(数据库名)恢复指定位置数据/usr/local/mysql/bin/mysqlbinlog --no-defaults --start-position=\"408\" --stop-position=\"731\" /usr/local/mysql/data/binlog/mysql-bin.000001 |mysql -uroot -p tuling(数据库)恢复指定时间段数据/usr/local/mysql/bin/mysqlbinlog --no-defaults /usr/local/mysql/data/binlog/mysql-bin.000001 --stop-date= \"2018-03-02 12:00:00\" --start-date= \"2019-03-02 11:55:00\"|mysql -uroot -p test(数据库)
mysql内部组件
概述我们的数据库一般都会并发执行多个事务,多个事务可能会并发的对相同的一批数据进行增删改查操作,可能就会导致我们说的脏写、脏读、不可重复读、幻读这些问题。这些问题的本质都是数据库的多事务并发问题,为了解决多事务并发问题,数据库设计了事务隔离机制、锁机制、MVCC多版本并发控制隔离机制,用一整套机制来解决多事务并发问题。接下来,我们会深入讲解这些机制,让大家彻底理解数据库内部的执行原理。
(1)打开一个客户端A,并设置当前事务模式为repeatable read,查询表account的所有记录set tx_isolation='repeatable-read';
(2)在客户端A的事务提交之前,打开另一个客户端B,更新表account并提交
(3)在客户端A查询表account的所有记录,与步骤(1)查询结果一致,没有出现不可重复读的问题
(4)在客户端A,接着执行update account set balance = balance - 50 where id = 1,balance没有变成400-50=350,lilei的balance值用的是步骤2中的350来算的,所以是300,数据的一致性倒是没有被破坏。可重复读的隔离级别下使用了MVCC(multi-version concurrency control)机制,select操作不会更新版本号,是快照读(历史版本);insert、update和delete会更新版本号,是当前读(当前版本)。
(5)重新打开客户端B,插入一条新数据后提交
(6)在客户端A查询表account的所有记录,没有查出新增数据,所以没有出现幻读
7)验证幻读在客户端A执行update account set balance=888 where id = 4;能更新成功,再次查询能查到客户端B新增的数据
可重复读
读锁(共享锁,S锁(Shared)):针对同一份数据,多个读操作可以同时进行而不会互相影响,比如:select * from T where id=1 lock in share mode写锁(排它锁,X锁(eXclusive)):当前写操作没有完成前,它会阻断其他写锁和读锁,数据修改操作都会加写锁,查询也可以通过for update加写锁,比如:select * from T where id=1 for update意向锁(Intention Lock):又称I锁,针对表锁,主要是为了提高加表锁的效率,是mysql数据库自己加的。当有事务给表的数据行加了共享锁或排他锁,同时会给表设置一个标识,代表已经有行锁了,其他事务要想对表加表锁时,就不必逐行判断有没有行锁可能跟表锁冲突了,直接读这个标识就可以确定自己该不该加表锁。特别是表中的记录很多时,逐行判断加表锁的方式效率很低。而这个标识就是意向锁。意向锁主要分为:意向共享锁,IS锁,对整个表加共享锁之前,需要先获取到意向共享锁。意向排他锁,IX锁,对整个表加排他锁之前,需要先获取到意向排他锁。
间隙锁(Gap Lock)间隙锁,锁的就是两个值之间的空隙。Mysql默认级别是repeatable-read,有办法解决幻读问题吗?间隙锁在某些情况下可以解决幻读问题。
结论Innodb存储引擎由于实现了行级锁定,虽然在锁定机制的实现方面所带来的性能损耗可能比表级锁定会要更高一下,但是在整体并发处理能力方面要远远优于MYISAM的表级锁定的。当系统并发量高的时候,Innodb的整体性能和MYISAM相比就会有比较明显的优势了。但是,Innodb的行级锁定同样也有其脆弱的一面,当我们使用不当的时候,可能会让Innodb的整体性能表现不仅不能比MYISAM高,甚至可能会更差。
行锁分析通过检查InnoDB_row_lock状态变量来分析系统上的行锁的争夺情况show status like 'innodb_row_lock%';对各个状态量的说明如下:Innodb_row_lock_current_waits: 当前正在等待锁定的数量Innodb_row_lock_time: 从系统启动到现在锁定总时间长度Innodb_row_lock_time_avg: 每次等待所花平均时间Innodb_row_lock_time_max:从系统启动到现在等待最长的一次所花时间Innodb_row_lock_waits: 系统启动后到现在总共等待的次数对于这5个状态变量,比较重要的主要是:Innodb_row_lock_time_avg (等待平均时长)Innodb_row_lock_waits (等待总次数)Innodb_row_lock_time(等待总时长)尤其是当等待次数很高,而且每次等待时长也不小的时候,我们就需要分析系统中为什么会有如此多的等待,然后根据分析结果着手制定优化计划。
死锁set tx_isolation='repeatable-read';Session_1执行:select * from account where id=1 for update;Session_2执行:select * from account where id=2 for update;Session_1执行:select * from account where id=2 for update;Session_2执行:select * from account where id=1 for update;查看近期死锁日志信息:show engine innodb status\\G; 大多数情况mysql可以自动检测死锁并回滚产生死锁的那个事务,但是有些情况mysql没法自动检测死锁锁优化建议尽可能让所有数据检索都通过索引来完成,避免无索引行锁升级为表锁合理设计索引,尽量缩小锁的范围尽可能减少检索条件范围,避免间隙锁尽量控制事务大小,减少锁定资源量和时间长度,涉及事务加锁的sql尽量放在事务最后执行尽可能低级别事务隔离
行锁每次操作锁住一行数据。开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度最高。InnoDB与MYISAM的最大不同有两点:InnoDB支持事务(TRANSACTION)InnoDB支持行级锁行锁演示一个session开启事务更新不提交,另一个session更新同一条记录会阻塞,更新不同记录不会阻塞总结:InnoDB在执行查询语句SELECT时(非串行隔离级别),不会加锁。但是update、insert、delete操作会加行锁。简而言之,就是读锁会阻塞写,但是不会阻塞读。而写锁则会把读和写都阻塞。
锁分类从性能上分为乐观锁(用版本对比来实现)和悲观锁从对数据操作的粒度分,分为表锁和行锁从对数据库操作的类型分,分为读锁和写锁(都属于悲观锁),还有意向锁
undo日志版本链与read view机制详解 undo日志版本链是指一行数据被多个事务依次修改过后,在每个事务修改完后,Mysql会保留修改前的数据undo回滚 日志,并且用两个隐藏字段trx_id和roll_pointer把这些undo日志串联起来形成一个历史记录版本链(见下图,需参考视 频里的例子理解)在可重复读隔离级别,当事务开启,执行任何查询sql时会生成当前事务的一致性视图read-view,该视图在事务结束之前都不会变化(如果是读已提交隔离级别在每次执行查询sql时都会重新生成),这个视图由执行查询时所有未提交事务id数组(数组里最小的id为min_id)和已创建的最大事务id(max_id)组成,事务里的任何sql查询结果需要从对应版本链里的最新数据开始逐条跟read-view做比对从而得到最终的快照结果。版本链比对规则:1. 如果 row 的 trx_id 落在绿色部分( trx_id<min_id ),表示这个版本是已提交的事务生成的,这个数据是可见的;2. 如果 row 的 trx_id 落在红色部分( trx_id>max_id ),表示这个版本是由将来启动的事务生成的,是不可见的(若 row 的 trx_id 就是当前自己的事务是可见的);3. 如果 row 的 trx_id 落在黄色部分(min_id <=trx_id<= max_id),那就包括两种情况 a. 若 row 的 trx_id 在视图数组中,表示这个版本是由还没提交的事务生成的,不可见(若 row 的 trx_id 就是当前自己的事务是可见的); b. 若 row 的 trx_id 不在视图数组中,表示这个版本是已经提交了的事务生成的,可见。对于删除的情况可以认为是update的特殊情况,会将版本链上最新的数据复制一份,然后将trx_id修改成删除操作的trx_id,同时在该条记录的头信息(record header)里的(deleted_flag)标记位写上true,来表示当前记录已经被删除,在查询时按照上面的规则查到对应的记录如果delete_flag标记位为true,意味着记录已被删除,则不返回数据。注意:begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个修改操作InnoDB表的语句,事务才真正启动,才会向mysql申请事务id,mysql内部是严格按照事务的启动顺序来分配事务id的。总结:MVCC机制的实现就是通过read-view机制与undo版本链比对机制,使得不同的事务会根据数据版本链对比规则读取同一条数据在版本链上的不同版本数据。
Innodb引擎SQL执行的BufferPool缓存机制为什么Mysql不能直接更新磁盘上的数据而且设置这么一套复杂的机制来执行SQL了?因为来一个请求就直接对磁盘文件进行随机读写,然后更新磁盘文件里的数据性能可能相当差。因为磁盘随机读写的性能是非常差的,所以直接更新磁盘文件是不能让数据库抗住很高并发的。Mysql这套机制看起来复杂,但它可以保证每个更新请求都是更新内存BufferPool,然后顺序写日志文件,同时还能保证各种异常情况下的数据一致性。更新内存的性能是极高的,然后顺序写磁盘上的日志文件的性能也是非常高的,要远高于随机读写磁盘文件。正是通过这套机制,才能让我们的MySQL数据库在较高配置的机器上每秒可以抗下几干甚至上万的读写请求。
MVCC多版本并发控制机制Mysql在可重复读隔离级别下如何保证事务较高的隔离性,我们上节课给大家演示过,同样的sql查询语句在一个事务 里多次执行查询结果相同,就算其它事务对数据有修改也不会影响当前事务sql语句的查询结果。 这个隔离性就是靠MVCC(Multi-Version Concurrency Control)机制来保证的,对一行数据的读和写两个操作默认 是不会通过加锁互斥来保证隔离性,避免了频繁加锁互斥,而在串行化隔离级别为了保证较高的隔离性是通过将所有操 作加锁互斥来实现的。Mysql在读已提交和可重复读隔离级别下都实现了MVCC机制。
锁详解锁是计算机协调多个进程或线程并发访问某一资源的机制。在数据库中,除了传统的计算资源(如CPU、RAM、I/O等)的争用以外,数据也是一种供需要用户共享的资源。如何保证数据并发访问的一致性、有效性是所有数据库必须解决的一个问题,锁冲突也是影响数据库并发访问性能的一个重要因素。
事务隔离级别
如果要保证数据能够实时同步,对于MySQL,通常就要用到他自身提供的一套通过Binlog日志在多个MySQL服务之间进行同步的集群方案。基于这种集群方案,一方面可以提高数据的安全性,另外也可以以此为基础,提供读写分离、故障转移等其他高级的功能。即在主库上打开Binlog日志,记录对数据的每一步操作。然后在从库上打开RelayLog日志,用来记录跟主库一样的Binlog日志,并将RelayLog中的操作日志在自己数据库中进行重演。这样就能够更加实时的保证主库与从库的数据一致。MySQL的Binlog默认是不打开的。 他的实现过程是在从库上启动一系列IO线程,负责与主库建立TCP连接,请求主库在写入Binlog日志时,也往从库传输一份。这时,主库上会有一个IO Dump线程,负责将Binlog日志通过这些TCP连接传输给从库的IO线程。而从库为了保证日志接收的稳定性,并不会立即重演Binlog数据操作,而是先将接收到的Binlog日志写入到自己的RelayLog日志当中。然后再异步的重演RelayLog中的数据操作。 MySQL的BinLog日志能够比较实时的记录主库上的所有日志操作,因此他也被很多其他工具用来实时监控MySQL的数据变化。例如Canal框架,可以模拟一个slave节点,同步MySQL的Binlog,然后将具体的数据操作按照定制的逻辑进行转发。例如转发到Redis实现缓存一致,转发到Kafka实现数据实时流转等。而ClickHouse也支持将自己模拟成一个MySQL的从节点,接收MySQL的Binlog日志,实时同步MySQL的数据。这个功能目前还在实验阶段。
MySQL主从同步原理
垂直分片: 按照业务来对数据进行分片,又称为纵向分片。他的核心理念就是转库专用。在拆分之前,一个数据库由多个数据表组成,每个表对应不同的业务。而拆分之后,则是按照业务将表进行归类,分布到不同的数据库或表中,从而将压力分散至不同的数据库或表。例如,下图将用户表和订单表垂直分片到不同的数据库:垂直分片往往需要对架构和设计进行调整。通常来讲,是来不及应对业务需求快速变化的。而且,他也无法真正的解决单点数据库的性能瓶颈。垂直分片可以缓解数据量和访问量带来的问题,但无法根治。如果垂直分片之后,表中的数据量依然超过单节点所能承载的阈值,则需要水平分片来进一步处理。
常用的分片策略有: 取余\\取模 : 优点 均匀存放数据,缺点 扩容非常麻烦 按照范围分片 : 比较好扩容, 数据分布不够均匀 按照时间分片 : 比较容易将热点数据区分出来。 按照枚举值分片 : 例如按地区分片 按照目标字段前缀指定进行分区:自定义业务规则分片水平分片从理论上突破了单机数据量处理的瓶颈,并且扩展相对自由,是分库分表的标准解决方案。 一般来说,在系统设计阶段就应该根据业务耦合松紧来确定垂直分库,垂直分表方案,在数据量及访问压力不是特别大的情况,首先考虑缓存、读写分离、索引技术等方案。若数据量极大,且持续增长,再考虑水平分库水平分表方案。
水平分片:又称横向分片。相对于垂直分片,它不再将数据根据业务逻辑分类,而是通过某个字段(或某几个字段),根据某种规则将数据分散至多个库或表中,每个分片仅包含数据的一部分。例如,像下图根据主键机构分片。
事务一致性问题原本单机数据库有很好的事务机制能够帮我们保证数据一致性。但是分库分表后,由于数据分布在不同库甚至不同服务器,不可避免会带来分布式事务问题。
跨节点关联查询问题在没有分库时,我们可以进行很容易的进行跨表的关联查询。但是在分库后,表被分散到了不同的数据库,就无法进行关联查询了。这时就需要将关联查询拆分成多次查询,然后将获得的结果进行拼装。
跨节点分页、排序函数跨节点多库进行查询时,limit分页、order by排序等问题,就变得比较复杂了。需要先在不同的分片节点中将数据进行排序并返回,然后将不同分片返回的结果集进行汇总和再次排序。这时非常容易出现内存崩溃的问题。
主键避重问题在分库分表环境中,由于表中数据同时存在不同数据库中,主键值平时使用的自增长将无用武之地,某个分区数据库生成的ID无法保证全局唯一。因此需要单独设计全局主键,以避免跨库主键重复问题。
公共表处理实际的应用场景中,参数表、数据字典表等都是数据量较小,变动少,而且属于高频联合查询的依赖表。这一类表一般就需要在每个数据库中都保存一份,并且所有对公共表的操作都要分发到所有的分库去执行。
运维工作量面对散乱的分库分表之后的数据,应用开发工程师和数据库管理员对数据库的操作都变得非常繁重。对于每一次数据读写操作,他们都需要知道要往哪个具体的数据库的分表去操作,这也是其中重要的挑战之一。
分库分表要解决哪些问题
分库分表的方式分库分表包含分库和分表 两个部分,而这两个部分可以统称为数据分片,其目的都是将数据拆分成不同的存储单元。另外,从分拆的角度上,可以分为垂直分片和水平分片。
MySQL高可用集群
Mysql数据库
MongoDB 版本变迁
MongoDB vs 关系型数据库概念MongoDB概念与关系型数据库(RDBMS)非常类似:数据库(database):最外层的概念,可以理解为逻辑上的名称空间,一个数据库包含多个不同名称的集合。集合(collection):相当于SQL中的表,一个集合可以存放多个不同的文档。文档(document):一个文档相当于数据表中的一行,由多个不同的字段组成。字段(field):文档中的一个属性,等同于列(column)。索引(index):独立的检索式数据结构,与SQL概念一致。 _id:每个文档中都拥有一个唯一的_id字段,相当于SQL中的主键(primary key)。视图(view):可以看作一种虚拟的(非真实存在的)集合,与SQL中的视图类似。从MongoDB 3.4版本开始提供了视图功能,其通过聚合管道技术实现。 聚合操作($lookup):MongoDB用于实现“类似”表连接(tablejoin)的聚合操作符。
尽管这些概念大多与SQL标准定义类似,但MongoDB与传统RDBMS仍然存在不少差异,包括:半结构化,在一个集合中,文档所拥有的字段并不需要是相同的,而且也不需要对所用的字段进行声明。因此,MongoDB具有很明显的半结构化特点。除了松散的表结构,文档还可以支持多级的嵌套、数组等灵活的数据类型,非常契合面向对象的编程模型。弱关系,MongoDB没有外键的约束,也没有非常强大的表连接能力。类似的功能需要使用聚合管道技术来弥补。
1.1 什么是MongoDBMongoDB是一个文档数据库(以 JSON 为数据模型),由C++语言编写,旨在为WEB应用提供可扩展的高性能数据存储解决方案。文档来自于“JSON Document”,并非我们一般理解的 PDF,WORD 文档。MongoDB是一个介于关系数据库和非关系数据库之间的产品,是非关系数据库当中功能最丰富,最像关系数据库的。它支持的数据结构非常松散,数据格式是BSON,一种类似JSON的二进制形式的存储格式,简称Binary JSON ,和JSON一样支持内嵌的文档对象和数组对象,因此可以存储比较复杂的数据类型。Mongo最大的特点是它支持的查询语言非常强大,其语法有点类似于面向对象的查询语言,几乎可以实现类似关系数据库单表查询的绝大部分功能,而且还支持对数据建立索引。原则上 Oracle 和 MySQL 能做的事情,MongoDB 都能做(包括 ACID 事务)。MongoDB是一个开源OLTP数据库,它灵活的文档模型(JSON)非常适合敏捷式开发、高可用和水平扩展的大数据应用。 OLTP:on-line Transaction Processing,联机(在线)事务处理OLAP:on-line Analytical Processing,联机(在线)分析处理MongoDB在数据库总排名第5,仅次于Oracle、MySQL等RDBMS,在NoSQL数据库排名首位。从诞生以来,其项目应用广度、社区活跃指数持续上升。
MongoDB优势:原生的高可用
MongoDB优势:横向扩展能力
1.2 MongoDB技术优势MongoDB基于灵活的JSON文档模型,非常适合敏捷式的快速开发。与此同时,其与生俱来的高可用、高水平扩展能力使得它在处理海量、高并发的数据应用时颇具优势。JSON 结构和对象模型接近,开发代码量低JSON的动态模型意味着更容易响应新的业务需求复制集提供99.999%高可用分片架构支持海量数据和无缝扩容
1.3 MongoDB应用场景从目前阿里云 MongoDB 云数据库上的用户看,MongoDB 的应用已经渗透到各个领域:游戏场景,使用 MongoDB 存储游戏用户信息,用户的装备、积分等直接以内嵌文档的形式存储,方便查询、更新;物流场景,使用 MongoDB 存储订单信息,订单状态在运送过程中会不断更新,以MongoDB 内嵌数组的形式来存储,一次查询就能将订单所有的变更读取出来;社交场景,使用 MongoDB 存储存储用户信息,以及用户发表的朋友圈信息,通过地理位置索引实现附近的人、地点等功能;物联网场景,使用 MongoDB 存储所有接入的智能设备信息,以及设备汇报的日志信息,并对这些信息进行多维度的分析;视频直播,使用 MongoDB 存储用户信息、礼物信息等;大数据应用,使用云数据库MongoDB作为大数据的云存储系统,随时进行数据提取分析,掌握行业动态。
数据库操作#查看所有库show dbs# 切换到指定数据库,不存在则创建use test# 删除当前数据库 db.dropDatabase()
集合操作#查看集合show collections#创建集合db.createCollection(\"emp\")#删除集合db.emp.drop()
mongo shell常用命令命令说明show dbs | show databases显示数据库列表use 数据库名切换数据库,如果不存在创建数据库db.dropDatabase()删除数据库show collections | show tables显示当前数据库的集合列表db.集合名.stats() 查看集合详情db.集合名.drop() 删除集合show users显示当前数据库的用户列表show roles显示当前数据库的角色列表show profile显示最近发生的操作load(\"xxx.js\")执行一个JavaScript脚本文件exit | quit()退出当前shellhelp查看mongodb支持哪些命令db.help()查询当前数据库支持的方法db.集合名.help()显示集合的帮助信息db.version()查看数据库版本
常用权限权限名 描述read 允许用户读取指定数据库readWrite 允许用户读写指定数据库dbAdmin 允许用户在指定数据库中执行管理函数,如索引创建、删除,查看统计或访问system.profiledbOwner 允许用户在指定数据库中执行任意操作,增、删、改、查等userAdmin 允许用户向system.users集合写入,可以在指定数据库里创建、删除和管理用户clusterAdmin 只在admin数据库中可用,赋予用户所有分片和复制集相关函数的管理权限readAnyDatabase 只在admin数据库中可用,赋予用户所有数据库的读权限readWriteAnyDatabase 只在admin数据库中可用,赋予用户所有数据库的读写权限userAdminAnyDatabase 只在admin数据库中可用,赋予用户所有数据库的userAdmin权限dbAdminAnyDatabase 只在admin数据库中可用,赋予用户所有数据库的dbAdmin权限root 只在admin数据库中可用。超级账号,超级权限
创建管理员账号# 设置管理员用户名密码需要切换到admin库use admin #创建管理员db.createUser({user:\"fox\
重新赋予用户操作权限db.grantRolesToUser( \"fox\
删除用户db.dropUser(\"fox\")#删除当前数据库所有用户 db.dropAllUser()
创建应用数据库用户use appdbdb.createUser({user:\"appdb\
2.3 安全认证创建管理员账号
3.1 插入文档3.2 版本之后新增了 db.collection.insertOne() 和 db.collection.insertMany()。
查询条件对照表SQL MQLa = 1 {a: 1}a <> 1 {a: {$ne: 1}}a > 1 {a: {$gt: 1}}a >= 1 {a: {$gte: 1}}a < 1 {a: {$lt: 1}}a <= 1 {a: {$lte: 1}}
查询逻辑运算符$lt: 存在并小于$lte: 存在并小于等于$gt: 存在并大于$gte: 存在并大于等于$ne: 不存在或存在但不等于$in: 存在并在指定数组中$nin: 不存在或不在指定数组中$or: 匹配两个或多个条件中的一个$and: 匹配全部条件
条件查询指定条件查询#查询带有nosql标签的book文档:db.books.find({tag:\"nosql\"})#按照id查询单个book文档:db.books.find({_id:ObjectId(\"61caa09ee0782536660494d9\")})#查询分类为“travel”、收藏数超过60个的book文档:db.books.find({type:\"travel\
排序&分页指定排序在 MongoDB 中使用 sort() 方法对数据进行排序#指定按收藏数(favCount)降序返回db.books.find({type:\"travel\"}).sort({favCount:-1})1 为升序排列,而 -1 是用于降序排列
处理分页问题 – 巧分页 数据量大的时候,应该避免使用skip/limit形式的分页。替代方案:使用查询条件+唯一排序条件;例如: 第一页:db.posts.find({}).sort({_id: 1}).limit(20); 第二页:db.posts.find({_id: {$gt: <第一页最后一个_id>}}).sort({_id: 1}).limit(20); 第三页:db.posts.find({_id: {$gt: <第二页最后一个_id>}}).sort({_id: 1}).limit(20);
处理分页问题 – 避免使用 count 尽可能不要计算总页数,特别是数据量大和查询条件不能完整命中索引时。 考虑以下场景:假设集合总共有 1000w 条数据,在没有索引的情况下考虑以下查询:db.coll.find({x: 100}).limit(50);db.coll.count({x: 100}); 前者只需要遍历前 n 条,直到找到 50 条 x=100 的文档即可结束; 后者需要遍历完 1000w 条找到所有符合要求的文档才能得到结果。 为了计算总页数而进行的 count() 往往是拖慢页面整体加载速度的原因
分页查询skip用于指定跳过记录数,limit则用于限定返回结果数量。可以在执行find命令的同时指定skip、limit参数,以此实现分页的功能。比如,假定每页大小为8条,查询第3页的book文档:db.books.find().skip(8).limit(4)
正则表达式匹配查询MongoDB 使用 $regex 操作符来设置匹配字符串的正则表达式。//使用正则表达式查找type包含 so 字符串的bookdb.books.find({type:{$regex:\"so\"}})//或者db.books.find({type:/so/})
更新操作符操作符 格式 描述$set {$set:{field:value}} 指定一个键并更新值,若键不存在则创建$unset {$unset : {field : 1 }} 删除一个键$inc {$inc : {field : value } } 对数值类型进行增减$rename {$rename : {old_field_name : new_field_name } } 修改字段名称$push {$push : {field : value } } 将数值追加到数组中,若数组不存在则会进行初始化$pushAll {$pushAll : {field : value_array }} 追加多个值到一个数组字段内$pull {$pull : {field : _value } } 从数组中删除指定的元素$addToSet {$addToSet : {field : value }} 添加元素到数组中,具有排重功能$pop {$pop : {field : 1 }} 删除数组的第一个或最后一个元素
更新单个文档某个book文档被收藏了,则需要将该文档的favCount字段自增db.books.update({_id:ObjectId(\"61caa09ee0782536660494d9\
更新多个文档默认情况下,update命令只在更新第一个文档之后返回,如果需要更新多个文档,则可以使用multi选项。将分类为“novel”的文档的增加发布时间(publishedDate)db.books.update({type:\"novel\
update命令的选项配置较多,为了简化使用还可以使用一些快捷命令:updateOne:更新单个文档。updateMany:更新多个文档。replaceOne:替换单个文档。
使用upsert命令upsert是一种特殊的更新,其表现为如果目标文档不存在,则执行插入命令。db.books.update( {title:\"my book\
实现replace语义update命令中的更新描述(update)通常由操作符描述,如果更新描述中不包含任何操作符,那么MongoDB会实现文档的replace语义db.books.update( {title:\"my book\
findAndModify命令findAndModify兼容了查询和修改指定文档的功能,findAndModify只能更新单个文档//将某个book文档的收藏数(favCount)加1db.books.findAndModify({ query:{_id:ObjectId(\"61caa09ee0782536660494dd\
使用 delete 删除文档官方推荐使用 deleteOne() 和 deleteMany() 方法删除文档,语法格式如下:db.books.deleteMany ({}) //删除集合下全部文档db.books.deleteMany ({ type:\"novel\" }) //删除 type等于 novel 的全部文档db.books.deleteOne ({ type:\"novel\" }) //删除 type等于novel 的一个文档注意: remove、deleteMany等命令需要对查询范围内的文档逐个删除,如果希望删除整个集合,则使用drop命令会更加高效
返回被删除文档remove、deleteOne等命令在删除文档后只会返回确认性的信息,如果希望获得被删除的文档,则可以使用findOneAndDelete命令db.books.findOneAndDelete({type:\"novel\"})除了在结果中返回删除文档,findOneAndDelete命令还允许定义“删除的顺序”,即按照指定顺序删除找到的第一个文档db.books.findOneAndDelete({type:\"novel\
文档操作最佳实践关于文档结构 防止使用太长的字段名(浪费空间)防止使用太深的数组嵌套(超过2层操作比较复杂)不使用中文,标点符号等非拉丁字母作为字段名关于写操作update 语句里只包括需要更新的字段 尽可能使用批量插入来提升写入性能 使用TTL自动过期日志类型的数据
3. MongoDB文档操作
为了避免文档的_id字段出现重复,ObjectId被定义为3个部分:4字节表示Unix时间戳(秒)。5字节表示随机数(机器号+进程号唯一)。 3字节表示计数器(初始化时随机)。大多数客户端驱动都会自行生成这个字段,比如MongoDB Java Driver会根据插入的文档是否包含_id字段来自动补充ObjectId对象。这样做不但提高了离散性,还可以降低MongoDB服务器端的计算压力。在ObjectId的组成中,5字节的随机数并没有明确定义,客户端可以采用机器号、进程号来实现:生成一个新的 ObjectIdx = ObjectId()
4.3 ObjectId生成器MongoDB集合中所有的文档都有一个唯一的_id字段,作为集合的主键。在默认情况下,_id字段使用ObjectId类型,采用16进制编码形式,共12个字节。
内嵌文档一个文档中可以包含作者的信息,包括作者名称、性别、家乡所在地,一个显著的优点是,当我们查询book文档的信息时,作者的信息也会一并返回。db.books.insert({ title: \"撒哈拉的故事\
查询数组元素# 会查询到所有的tagsdb.books.find({\"author.name\":\"三毛\
数组末尾追加元素,可以使用$push操作符db.books.updateOne({\"author.name\":\"三毛\
根据元素查询#会查出所有包含伤感的文档db.books.find({tags:\"伤感\"})# 会查出所有同时包含\"伤感\
一个商品可以同时包含多个维度的属性,比如尺码、颜色、风格等,使用文档可以表示为:db.goods.insertMany([{ name:\"羽绒服\
数组除了作者信息,文档中还包含了若干个标签,这些标签可以用来表示文档所包含的一些特征,如豆瓣读书中的标签(tag)增加tags标签db.books.updateOne({\"author.name\":\"三毛\
4.4 内嵌文档和数组
创建固定集合db.createCollection(\"logs\
优势与限制固定集合在底层使用的是顺序I/O操作,而普通集合使用的是随机I/O。顺序I/O在磁盘操作上由于寻道次数少而比随机I/O要高效得多,因此固定集合的写入性能是很高的。此外,如果按写入顺序进行数据读取,也会获得非常好的性能表现。但它也存在一些限制,主要有如下5个方面:1.无法动态修改存储的上限,如果需要修改max或size,则只能先执行collection.drop命令,将集合删除后再重新创建。2.无法删除已有的数据,对固定集合中的数据进行删除将会得到如下错误:
3. 对已有数据进行修改,新文档大小必须与原来的文档大小一致,否则不允许更新:
4. 默认情况下,固定集合只有一个_id索引,而且最好是按数据写入的顺序进行读取。当然,也可以添加新的索引,但这会降低数据写入的性能。5. 固定集合不支持分片,同时,在MongoDB 4.2版本中规定了事务中也无法对固定集合执行写操作。
适用场景固定集合很适合用来存储一些“临时态”的数据。“临时态”意味着数据在一定程度上可以被丢弃。同时,用户还应该更关注最新的数据,随着时间的推移,数据的重要性逐渐降低,直至被淘汰处理。一些适用的场景如下:系统日志,这非常符合固定集合的特征,而日志系统通常也只需要一个固定的空间来存放日志。在MongoDB内部,副本集的同步日志(oplog)就使用了固定集合。存储少量文档,如最新发布的TopN条文章信息。得益于内部缓存的作用,对于这种少量文档的查询是非常高效的。
1. 创建stock_queue消息队列,其可以容纳10MB的数据db.createCollection(\"stock_queue\
使用固定集合实现FIFO队列在股票实时系统中,大家往往最关心股票价格的变动。而应用系统中也需要根据这些实时的变化数据来分析当前的行情。倘若将股票的价格变化看作是一个事件,而股票交易所则是价格变动事件的“发布者”,股票APP、应用系统则是事件的“消费者”。这样,我们就可以将股票价格的发布、通知抽象为一种数据的消费行为,此时往往需要一个消息队列来实现该需求。结合业务场景: 利用固定集合实现存储股票价格变动信息的消息队列
4.5 固定集合固定集合(capped collection)是一种限定大小的集合,其中capped是覆盖、限额的意思。跟普通的集合相比,数据在写入这种集合时遵循FIFO原则。可以将这种集合想象为一个环状的队列,新文档在写入时会被插入队列的末尾,如果队列已满,那么之前的文档就会被新写入的文档所覆盖。通过固定集合的大小,我们可以保证数据库只会存储“限额”的数据,超过该限额的旧数据都会被丢弃。
4. MongoDB数据模型思考:MongoDB为什么会使用BSON?
CheckPoint(检查点)机制快照(snapshot)描述了某一时刻(point-in-time)数据在内存中的一致性视图,而这种数据的一致性是WiredTiger通过MVCC(多版本并发控制)实现的。当建立CheckPoint时,WiredTiger会在内存中建立所有数据的一致性快照,并将该快照覆盖的所有数据变化一并进行持久化(fsync)。成功之后,内存中数据的修改才得以真正保存。默认情况下,MongoDB每60s建立一次CheckPoint,在检查点写入过程中,上一个检查点仍然是可用的。这样可以保证一旦出错,MongoDB仍然能恢复到上一个检查点。
WiredTiger写入数据的流程:应用向MongoDB写入数据(插入、修改或删除)。数据库从内部缓存中获取当前记录所在的页块,如果不存在则会从磁盘中加载(Buffer I/O) WiredTiger开始执行写事务,修改的数据写入页块的一个更新记录表,此时原来的记录仍然保持不变。 如果开启了Journal日志,则在写数据的同时会写入一条Journal日志(Redo Log)。该日志在最长不超过100ms之后写入磁盘数据库每隔60s执行一次CheckPoint操作,此时内存中的修改会真正刷入磁盘。Journal日志的刷新周期可以通过参数storage.journal.commitIntervalMs指定,MongoDB 3.4及以下版本的默认值是50ms,而3.6版本之后调整到了100ms。由于Journal日志采用的是顺序I/O写操作,频繁地写入对磁盘的影响并不是很大。CheckPoint的刷新周期可以调整storage.syncPeriodSecs参数(默认值60s),在MongoDB 3.4及以下版本中,当Journal日志达到2GB时同样会触发CheckPoint行为。如果应用存在大量随机写入,则CheckPoint可能会造成磁盘I/O的抖动。在磁盘性能不足的情况下,问题会更加显著,此时适当缩短CheckPoint周期可以让写入平滑一些。
Journal日志Journal是一种预写式日志(write ahead log)机制,主要用来弥补CheckPoint机制的不足。如果开启了Journal日志,那么WiredTiger会将每个写操作的redo日志写入Journal缓冲区,该缓冲区会频繁地将日志持久化到磁盘上。默认情况下,Journal缓冲区每100ms执行一次持久化。此外,Journal日志达到100MB,或是应用程序指定journal:true,写操作都会触发日志的持久化。一旦MongoDB发生宕机,重启程序时会先恢复到上一个检查点,然后根据Journal日志恢复增量的变化。由于Journal日志持久化的间隔非常短,数据能得到更高的保障,如果按照当前版本的默认配置,则其在断电情况下最多会丢失100ms的写入数据。
写缓冲当数据发生写入时,MongoDB并不会立即持久化到磁盘上,而是先在内存中记录这些变更,之后通过CheckPoint机制将变化的数据写入磁盘。为什么要这么处理?主要有以下两个原因:如果每次写入都触发一次磁盘I/O,那么开销太大,而且响应时延会比较大。多个变更的写入可以尽可能进行I/O合并,降低资源负荷。思考:MongoDB会丢数据吗?MongoDB单机下保证数据可靠性的机制包括以下两个部分:CheckPoint(检查点)机制
5.2 WiredTiger读写模型
5. WiredTiger读写模型详解
MongoDB介绍
聚合操作处理数据记录并返回计算结果。聚合操作组值来自多个文档,可以对分组数据执行 各种操作以返回单个结果。聚合操作包含三类:单一作用聚合、聚合管道、MapReduce。 单一作用聚合:提供了对常见聚合过程的简单访问,操作都从单个集合聚合文档。 聚合管道是一个数据聚合的框架,模型基于数据处理流水线的概念。文档进入多 级管道,将 文档转换为聚合结果。 MapReduce操作具有两个阶段:处理每个文档并向每个输入文档发射一个或多 个对象的map阶段,以及reduce组合map操作的输出阶段。
db.collection.estimatedDocumentCount() 返回集合或视图中所有文档的计数db.collection.count() 返回与find()集合或视图的查询匹配的文档计数 。等同于 db.collection.find(query).count()构造db.collection.distinct() 在单个集合或视图中查找指定字段的不同值,并在数组中返回结果。#检索books集合中所有文档的计数db.books.estimatedDocumentCount()#计算与查询匹配的所有文档db.books.count({favCount:{$gt:50}})#返回不同type的数组db.books.distinct(\"type\")#返回收藏数大于90的文档不同type的数组db.books.distinct(\"type\
管道(Pipeline)和阶段(Stage)整个聚合运算过程称为管道(Pipeline),它是由多个阶段(Stage)组成的, 每个管道: 接受一系列文档(原始数据); 每个阶段对这些文档进行一系列运算; 结果文档输出给下一个阶段;
聚合表达式获取字段信息$<field> : 用 $ 指示字段路径$<field>.<sub field> : 使用 $ 和 . 来指示内嵌文档的路径常量表达式$literal :<value> : 指示常量 <value>系统变量表达式$$<variable> 使用 $$ 指示系统变量$$CURRENT 指示管道中当前操作的文档
常用的管道聚合阶段聚合管道包含非常丰富的聚合阶段,下面是最常用的聚合阶段阶段 描述 SQL等价运算符$match 筛选条件 WHERE$project 投影 AS$lookup 左外连接 LEFT OUTER JOIN$sort 排序 ORDER BY$group 分组 GROUP BY$skip/$limit 分页$unwind 展开数组$graphLookup 图搜索$facet/$bucket 分面搜索文档:Aggregation Pipeline Stages — MongoDB Manual
$project投影操作, 将原始字段投影成指定名称, 如将集合中的 title 投影成 namedb.books.aggregate([{$project:{name:\"$title\"}}])$project 可以灵活控制输出文档的格式,也可以剔除不需要的字段db.books.aggregate([{$project:{name:\"$title\
$match$match用于对文档进行筛选,之后可以在得到的文档子集上做聚合,$match可以使用除了地理空间之外的所有常规查询操作符,在实际应用中尽可能将$match放在管道的前面位置。这样有两个好处:一是可以快速将不需要的文档过滤掉,以减少管道的工作量;二是如果再投射和分组之前执行$match,查询可以使用索引。db.books.aggregate([{$match:{type:\"technology\"}}])筛选管道操作和其他管道操作配合时候时,尽量放到开始阶段,这样可以减少后续管道操作符要操作的文档数,提升效率db.books.aggregate([ {$match:{type:\"technology\
$count计数并返回与查询匹配的结果数db.books.aggregate([ {$match:{type:\"technology\
统计每个作者的book收藏总数db.books.aggregate([ {$group:{_id:\"$author.name\
统计每个作者的每本book的收藏数db.books.aggregate([ {$group:{_id:{name:\"$author.name\
每个作者的book的type合集db.books.aggregate([ {$group:{_id:\"$author.name\
案例示例数据db.books.insert([{ \"title\" : \"book-51\
$limit限制传递到管道中下一阶段的文档数db.books.aggregate([ {$limit : 5 }])此操作仅返回管道传递给它的前5个文档。 $limit对其传递的文档内容没有影响。注意:当$sort在管道中的$limit之前立即出现时,$sort操作只会在过程中维持前n个结果,其中n是指定的限制,而MongoDB只需要将n个项存储在内存中。
$skip跳过进入stage的指定数量的文档,并将其余文档传递到管道中的下一个阶段db.books.aggregate([ {$skip : 5 }])此操作将跳过管道传递给它的前5个文档。 $skip对沿着管道传递的文档的内容没有影响。
聚合操作案例1统计每个分类的book文档数量db.books.aggregate([ {$group:{_id:\"$type\
$lookup Mongodb 3.2版本新增,主要用来实现多表关联查询, 相当关系型数据库中多表关联查询。每个输入待处理的文档,经过$lookup 阶段的处理,输出的新文档中会包含一个新生成的数组(可根据需要命名新key )。数组列存放的数据是来自被Join集合的适配文档,如果没有,集合为空(即 为[ ])语法:db.collection.aggregate([{ $lookup: { from: \"<collection to join>\
数据准备准备数据集,执行脚本var tags = [\"nosql\
1.2 聚合管道什么是 MongoDB 聚合框架MongoDB 聚合框架(Aggregation Framework)是一个计算框架,它可以:作用在一个或几个集合上; 对集合中的数据进行的一系列运算;将这些数据转化为期望的形式;从效果而言,聚合框架相当于 SQL 查询中的GROUP BY、 LEFT OUTER JOIN 、 AS等。
1.3 MapReduceMapReduce操作将大量的数据处理工作拆分成多个线程并行处理,然后将结果合并在一起。MongoDB提供的Map-Reduce非常灵活,对于大规模数据分析也相当实用。MapReduce具有两个阶段: 将具有相同Key的文档数据整合在一起的map阶段 组合map操作的结果进行统计输出的reduce阶段
聚合操作
MongoDB索引索引是一种用来快速查询数据的数据结构。B+Tree就是一种常用的数据库索引数据结构,MongoDB采用B+Tree 做索引,索引创建在colletions上。MongoDB不使用索引的查询,先扫描所有的文档,再匹配符合条件的文档。 使用索引的查询,通过索引找到文档,使用索引能够极大的提升查询效率。思考:MongoDB索引数据结构是B-Tree还是B+Tree?
B+ Tree中的leaf page包含一个页头(page header)、块头(block header)和真正的数据(key/value),其中页头定义了页的类型、页中实际载荷数据的大小、页中记录条数等信息;块头定义了此页的checksum、块在磁盘上的寻址位置等信息。WiredTiger有一个块设备管理的模块,用来为page分配block。如果要定位某一行数据(key/value)的位置,可以先通过block的位置找到此page(相对于文件起始位置的偏移量),再通过page找到行数据的相对位置,最后可以得到行数据相对于文件起始位置的偏移量offsets。
WiredTiger数据文件在磁盘的存储结构
索引设计原则1、每个查询原则上都需要创建对应索引2、单个索引设计应考虑满足尽量多的查询3、索引字段选择及顺序需要考虑查询覆盖率及选择性4、对于更新及其频繁的字段上创建索引需慎重5、对于数组索引需要慎重考虑未来元素个数6、对于超长字符串类型字段上慎用索引7、并发更新较高的单个集合上不宜创建过多索引
索引的分类按照索引包含的字段数量,可以分为单键索引和组合索引(或复合索引)。按照索引字段的类型,可以分为主键索引和非主键索引。按照索引节点与物理记录的对应方式来分,可以分为聚簇索引和非聚簇索引,其中聚簇索引是指索引节点上直接包含了数据记录,而后者则仅仅包含一个指向数据记录的指针。按照索引的特性不同,又可以分为唯一索引、稀疏索引、文本索引、地理空间索引等与大多数数据库一样,MongoDB支持各种丰富的索引类型,包括单键索引、复合索引,唯一索引等一些常用的结构。由于采用了灵活可变的文档类型,因此它也同样支持对嵌套字段、数组进行索引。通过建立合适的索引,我们可以极大地提升数据的检索速度。在一些特殊应用场景,MongoDB还支持地理空间索引、文本检索索引、TTL索引等不同的特性。
查看索引#查看索引信息db.books.getIndexes()#查看索引键db.books.getIndexKeys()查看索引占用空间db.collection.totalIndexSize([is_detail])is_detail:可选参数,传入除0或false外的任意数据,都会显示该集合中每个索引的大小及总大小。如果传入0或false则只显示该集合中所有索引的总大小。默认值为false。
删除索引#删除集合指定索引db.col.dropIndex(\"索引名称\")#删除集合所有索引 不能删除主键索引db.col.dropIndexes()
索引操作
在包含嵌套对象的数组字段上创建多键索引db.inventory.createIndex( { \"stock.size\
创建一个2dsphere索引db.restaurant.createIndex({location : \"2dsphere\"})
查询附近10000米商家信息db.restaurant.find( { location:{ $near :{ $geometry :{ type : \"Point\
案例:MongoDB如何实现“查询附近商家\
地理空间索引(Geospatial Index)在移动互联网时代,基于地理位置的检索(LBS)功能几乎是所有应用系统的标配。MongoDB为地理空间检索提供了非常方便的功能。地理空间索引(2dsphereindex)就是专门用于实现位置检索的一种特殊索引。
创建name和description的全文索引db.stores.createIndex({name: \"text\
全文索引(Text Indexes)MongoDB支持全文检索功能,可通过建立文本索引来实现简易的分词检索。db.reviews.createIndex( { comments: \"text\" } )$text操作符可以在有text index的集合上执行文本检索。$text将会使用空格和标点符号作为分隔符对检索字符串进行分词, 并且对检索字符串中所有的分词结果进行一个逻辑上的 OR 操作。全文索引能解决快速文本查找的需求,比如有一个博客文章集合,需要根据博客的内容来快速查找,则可以针对博客内容建立文本索引。
创建通配符索引db.products.createIndex( { \"product_attributes.$**\" : 1 } )
测试通配符索引可以支持任意单字段查询 product_attributes或其嵌入字段:db.products.find( { \"product_attributes.size.length\" : { $gt : 60 } } )db.products.find( { \"product_attributes.material\" : \"Leather\" } )db.products.find( { \"product_attributes.secret_feature.name\" : \"laser\" } )
案例准备商品数据,不同商品属性不一样db.products.insert([ { \"product_name\" : \"Spy Coat\
通配符索引不兼容的索引类型或属性
通配符索引是稀疏的,不索引空字段。因此,通配符索引不能支持查询字段不存在的文档。# 通配符索引不能支持以下查询db.products.find( {\"product_attributes\" : { $exists : false } } )db.products.aggregate([ { $match : { \"product_attributes\" : { $exists : false } } }])
通配符索引为文档或数组的内容生成条目,而不是文档/数组本身。因此通配符索引不能支持精确的文档/数组相等匹配。通配符索引可以支持查询字段等于空文档{}的情况。#通配符索引不能支持以下查询:db.products.find({ \"product_attributes.colors\" : [ \"Blue\
注意事项
通配符索引(Wildcard Indexes)MongoDB的文档模式是动态变化的,而通配符索引可以建立在一些不可预知的字段上,以此实现查询的加速。MongoDB 4.2 引入了通配符索引来支持对未知或任意字段的查询。
索引类型
测试db.restaurants.find( { borough: \"Bronx\
唯一约束结合部分索引使用导致唯一约束失效的问题注意:如果同时指定了partialFilterExpression和唯一约束,那么唯一约束只适用于满足筛选器表达式的文档。如果文档不满足筛选条件,那么带有惟一约束的部分索引不会阻止插入不满足惟一约束的文档。
案例1restaurants集合数据db.restaurants.insert({ \"_id\" : ObjectId(\"5641f6a7522545bc535b5dc9\
测试索引防止了以下文档的插入,因为文档已经存在,且指定的用户名和年龄字段大于21:db.users.insertMany( [ { username: \"david\
案例2users集合数据准备db.users.insertMany( [ { username: \"david\
测试# 使用稀疏索引db.scores.find( { score: { $lt: 90 } } )# 即使排序是通过索引字段,MongoDB也不会选择稀疏索引来完成查询,以返回完整的结果db.scores.find().sort( { score: -1 } )# 要使用稀疏索引,使用hint()显式指定索引db.scores.find().sort( { score: -1 } ).hint( { score: 1 } )同时具有稀疏性和唯一性的索引可以防止集合中存在字段值重复的文档,但允许不包含此索引字段的文档插入。
测试这个索引将允许插入具有唯一的分数字段值或不包含分数字段的文档。因此,给定scores集合中的现有文档,索引允许以下插入操作:db.scores.insertMany( [ { \"userid\": \"AAAAAAA\
索引不允许添加下列文件,因为已经存在评分为82和90的文件:db.scores.insertMany( [ { \"userid\": \"AAAAAAA\
案例数据准备db.scores.insertMany([ {\"userid\" : \"newbie\
稀疏索引(Sparse Indexes)索引的稀疏属性确保索引只包含具有索引字段的文档的条目,索引将跳过没有索引字段的文档。特性: 只对存在字段的文档进行索引(包括字段值为null的文档)#不索引不包含xmpp_id字段的文档db.addresses.createIndex( { \"xmpp_id\
通常的做法如下:方案一:为每个数据记录一个时间戳,应用侧开启一个定时器,按时间戳定期删除过期的数据。方案二:数据按日期进行分表,同一天的数据归档到同一张表,同样使用定时器删除过期的表。对于数据老化,MongoDB提供了一种更加便捷的做法:TTL(Time To Live)索引。TTL索引需要声明在一个日期类型的字段中,TTL 索引是特殊的单字段索引,MongoDB 可以使用它在一定时间或特定时钟时间后自动从集合中删除文档。# 创建 TTL 索引,TTL 值为3600秒db.eventlog.createIndex( { \"lastModifiedDate\
创建TTL索引db.log_events.createIndex( { \"createdAt\
可变的过期时间TTL索引在创建之后,仍然可以对过期时间进行修改。这需要使用collMod命令对索引的定义进行变更db.runCommand({collMod:\"log_events\
案例数据准备db.log_events.insertOne( { \"createdAt\
使用约束TTL索引的确可以减少开发的工作量,而且通过数据库自动清理的方式会更加高效、可靠,但是在使用TTL索引时需要注意以下的限制:TTL索引只能支持单个字段,并且必须是非_id字段。TTL索引不能用于固定集合。 TTL索引无法保证及时的数据老化,MongoDB会通过后台的TTLMonitor定时器来清理老化数据,默认的间隔时间是1分钟。当然如果在数据库负载过高的情况下,TTL的行为则会进一步受到影响。TTL索引对于数据的清理仅仅使用了remove命令,这种方式并不是很高效。因此TTL Monitor在运行期间对系统CPU、磁盘都会造成一定的压力。相比之下,按日期分表的方式操作会更加高效。日志存储:日期分表固定集合TTL索引插入: writeConcern:{w:0}
TTL索引(TTL Indexes)在一般的应用系统中,并非所有的数据都需要永久存储。例如一些系统事件、用户消息等,这些数据随着时间的推移,其重要程度逐渐降低。更重要的是,存储这些大量的历史数据需要花费较高的成本,因此项目中通常会对过期且不再使用的数据进行老化处理。
查看索引信息db.scores.getIndexes()索引属性hidden只在值为true时返回
测试# 不使用索引db.scores.find({userid:\"abby\"}).explain()#取消隐藏索引db.scores.unhideIndex( { userid: 1} )#使用索引db.scores.find({userid:\"abby\"}).explain()
案例db.scores.insertMany([ {\"userid\" : \"newbie\
索引属性
索引使用建议1.为每一个查询建立合适的索引这个是针对于数据量较大比如说超过几十上百万(文档数目)数量级的集合。如果没有索引MongoDB需要把所有的Document从盘上读到内存,这会对MongoDB服务器造成较大的压力并影响到其他请求的执行。2.创建合适的复合索引,不要依赖于交叉索引如果你的查询会使用到多个字段,MongoDB有两个索引技术可以使用:交叉索引和复合索引。交叉索引就是针对每个字段单独建立一个单字段索引,然后在查询执行时候使用相应的单字段索引进行索引交叉而得到查询结果。交叉索引目前触发率较低,所以如果你有一个多字段查询的时候,建议使用复合索引能够保证索引正常的使用。#查找所有年龄小于30岁的深圳市马拉松运动员db.athelets.find({sport: \"marathon\
通常我们需要关心的问题:查询是否使用了索引索引是否减少了扫描的记录数量是否存在低效的内存排序MongoDB提供了explain命令,它可以帮助我们评估指定查询模型(querymodel)的执行计划,根据实际情况进行调整,然后提高查询效率。
queryPlanner# 未创建title的索引db.books.find({title:\"book-1\"}).explain(\"queryPlanner\")字段名称 描述plannerVersion 执行计划的版本namespace 查询的集合indexFilterSet 是否使用索引parsedQuery 查询条件winningPlan 最佳执行计划stage 查询方式filter 过滤条件direction 查询顺序rejectedPlans 拒绝的执行计划serverInfo mongodb服务器信息
executionStatsexecutionStats 模式的返回信息中包含了 queryPlanner 模式的所有字段,并且还包含了最佳执行计划的执行情况#创建索引db.books.createIndex({title:1})db.books.find({title:\"book-1\"}).explain(\"executionStats\")字段名称 描述winningPlan.inputStage 用来描述子stage,并且为其父stage提供文档和索引关键字winningPlan.inputStage.stage 子查询方式winningPlan.inputStage.keyPattern 所扫描的index内容winningPlan.inputStage.indexName 索引名winningPlan.inputStage.isMultiKey 是否是Multikey。如果索引建立在array上,将是trueexecutionStats.executionSuccess 是否执行成功executionStats.nReturned 返回的个数executionStats.executionTimeMillis 这条语句执行时间executionStats.executionStages.executionTimeMillisEstimate 检索文档获取数据的时间executionStats.executionStages.inputStage.executionTimeMillisEstimate 扫描获取数据的时间executionStats.totalKeysExamined 索引扫描次数executionStats.totalDocsExamined 文档扫描次数executionStats.executionStages.isEOF 是否到达 steam 结尾,1 或者 true 代表已到达结尾executionStats.executionStages.works 工作单元数,一个查询会分解成小的工作单元executionStats.executionStages.advanced 优先返回的结果数executionStats.executionStages.docsExamined 文档检查数
allPlansExecutionallPlansExecution返回的信息包含 executionStats 模式的内容,且包含allPlansExecution:[]块\"allPlansExecution\" : [ { \"nReturned\
explain()方法的形式如下:db.collection.find().explain(<verbose>)verbose 可选参数,表示执行计划的输出模式,默认queryPlanner模式名字 描述queryPlanner 执行计划的详细信息,包括查询计划、集合信息、查询条件、最佳执行计划、查询方式和 MongoDB 服务信息等exectionStats 最佳执行计划的执行情况和被拒绝的计划等信息allPlansExecution 选择并执行最佳执行计划,并返回最佳执行计划和其他执行计划的执行情况
explain执行计划详解
基础知识
在实现高可用的同时,复制集实现了其他几个附加作用:数据分发: 将数据从一个区域复制到另一个区域,减少另一个区域的读延迟读写分离: 不同类型的压力分别在不同的节点上执行异地容灾: 在数据中心故障时候快速切换到异地早期版本的MongoDB使用了一种Master-Slave的架构,该做法在MongoDB 3.4版本之后已经废弃。
复制集架构在生产环境中,不建议使用单机版的MongoDB服务器。原因如下:单机版的MongoDB无法保证可靠性,一旦进程发生故障或是服务器宕机,业务将直接不可用。一旦服务器上的磁盘损坏,数据会直接丢失,而此时并没有任何副本可用。
PSS模式(官方推荐模式)PSS模式由一个主节点和两个备节点所组成,即Primary+Secondary+Secondary。
此模式始终提供数据集的两个完整副本,如果主节点不可用,则复制集选择备节点作为主节点并继续正常操作。旧的主节点在可用时重新加入复制集。
PSA模式PSA模式由一个主节点、一个备节点和一个仲裁者节点组成,即Primary+Secondary+Arbiter
其中,Arbiter节点不存储数据副本,也不提供业务的读写操作。Arbiter节点发生故障不影响业务,仅影响选举投票。此模式仅提供数据的一个完整副本,如果主节点不可用,则复制集将选择备节点作为主节点。
三节点复制集模式常见的复制集架构由3个成员节点组成,其中存在几种不同的模式。
复制集注意事项关于硬件:因为正常的复制集节点都有可能成为主节点,它们的地位是一样的,因此硬件配置上必须一致;为了保证节点不会同时宕机,各节点使用的硬件必须具有独立性。关于软件:复制集各节点软件版本必须一致,以避免出现不可预知的问题。增加节点不会增加系统写性能
环境准备安装 MongoDB并配置好环境变量确保有 10GB 以上的硬盘空间准备配置文件
方法1# mongo --port 28017# 初始化复制集> rs.initiate() # 将其余成员添加到复制集> rs.add(\"192.168.65.174:28018\") > rs.add(\"192.168.65.174:28019\")
方法2# mongo --port 28017 # 初始化复制集> rs.initiate({ _id: \"rs0\
配置复制集复制集通过replSetInitiate命令或mongo shell的rs.initiate()进行初始化,初始化后各个成员间开始发送心跳消息,并发起Priamry选举操作,获得『大多数』成员投票支持的节点,会成为Primary,其余节点成为Secondary。
验证MongoDB 主节点进行写入# mongo --port 28017rs0:PRIMARY> db.user.insert([{name:\"fox\
复制集状态查询查看复制集整体状态:rs.status()可查看各成员当前状态,包括是否健康,是否在全量同步,心跳信息,增量同步信息, 选举信息,上一次的心跳时间等。查看当前节点角色:db.isMaster()除了当前节点角色信息,是一个更精简化的信息,也返回整个复制集的成员列表,真正的Primary是谁,协议相关的配置信息等,Driver 在首次连接复制集时会发送该命令。
Mongo Shell复制集命令命令 描述rs.add() 为复制集新增节点rs.addArb() 为复制集新增一个 arbiterrs.conf() 返回复制集配置信息rs.freeze() 防止当前节点在一段时间内选举成为主节点rs.help() 返回 replica set 的命令帮助rs.initiate() 初始化一个新的复制集rs.printReplicationInfo() 以主节点的视角返回复制的状态报告rs.printSecondaryReplicationInfo()以从节点的视角返回复制状态报告rs.reconfig() 通过重新应用复制集配置来为复制集更新配置rs.remove() 从复制集中移除一个节点rs.secondaryOk() 为当前的连接设置 从节点可读rs.status() 返回复制集状态信息。rs.stepDown() 让当前的 primary 变为从节点并触发 electionrs.syncFrom() 设置复制集节点从哪个节点处同步数据,将会覆盖默认选取逻辑
使用mtools创建复制集文档:使用mtools搭建MongoDB复制集和分片集?..链接:http://note.youdao.com/noteshare?id=3c02251c8b4a8bfc98ab392146aa8222&sub=9E0834FE787F413E8EBA774596AB3999#准备复制集使用的工作目录mkdir -p /data/mongocd /data/mongo#初始化3节点复制集mlaunch init --replicaset --nodes 3端口默认从27017开始,依次为2017,27018,27019
安全认证创建用户在主节点服务器上,启动mongouse admin#创建用户db.createUser( { user: \"fox\
创建keyFile文件keyFile文件的作用: 集群之间的安全认证,增加安全认证机制KeyFile(开启keyfile认证就默认开启了auth认证了)。#mongo.key采用随机算法生成,用作节点内部通信的密钥文件。openssl rand -base64 756 > /data/mongo.key #权限必须是600 chmod 600 /data/mongo.key 注意:创建keyFile前,需要先停掉复制集中所有主从节点的mongod服务,然后再创建,否则有可能出现服务启动不了的情况。将主节点中的keyfile文件拷贝到复制集其他从节点服务器中,路径地址对应mongo.conf配置文件中的keyFile字段地址,并设置keyfile权限为600
启动mongod# 启动mongodmongod -f /data/db1/mongod.conf --keyFile /data/mongo.keymongod -f /data/db2/mongod.conf --keyFile /data/mongo.keymongod -f /data/db3/mongod.conf --keyFile /data/mongo.key
方式一:直接连接 Primary 节点,正常情况下可读写 MongoDB,但主节点故障切换后,无法正常访问
方式二(强烈推荐):通过高可用 Uri 的方式连接 MongoDB,当 Primary 故障切换后,MongoDB Driver 可自动感知并把流量路由到新的 Primary 节点
复制集连接方式
典型三节点复制集环境搭建即使暂时只有一台服务器,也要以单节点模式启动复制集单机多实例启动复制集单节点启动复制集
复制集里面有多个节点,每个节点拥有不同的职责。 在看成员角色之前,先了解两个重要属性: 属性一:Priority = 0 当 Priority 等于 0 时,它不可以被复制集选举为主,Priority 的值越高,则被选举为主的概率更大。通常,在跨机房方式下部署复制集可以使用该特性。假设使用了机房A和机房B,由于主要业务与机房A更近,则可以将机房B的复制集成员Priority设置为0,这样主节点就一定会是A机房的成员。 属性二:Vote = 0 不可以参与选举投票,此时该节点的 Priority 也必须为 0,即它也不能被选举为主。由于一个复制集中最多只有7个投票成员,因此多出来的成员则必须将其vote属性值设置为0,即这些成员将无法参与投票。
配置隐藏节点很多情况下将节点设置为隐藏节点是用来协助 delayed members 的。如果我们仅仅需要防止该节点成为主节点,我们可以通过 priority 0 member 来实现。cfg = rs.conf()cfg.members[1].priority = 0cfg.members[1].hidden = truers.reconfig(cfg)设置完毕后,该从节点的优先级将变为 0 来防止其升职为主节点,同时其也是对应用程序不可见的。在其他节点上执行 db.isMaster() 将不会显示隐藏节点。
查看复制延迟如果希望查看当前节点oplog的情况,则可以使用rs.printReplicationInfo()命令这里清晰地描述了oplog的大小、最早一条oplog以及最后一条oplog的产生时间,log length start to end所指的是一个复制窗口(时间差)。通常在oplog大小不变的情况下,业务写操作越频繁,复制窗口就会越短。在节点上执行rs.printSecondaryReplicationInfo()命令,可以一并列出所有备节点成员的同步延迟情况
配置延时节点当我们配置一个延时节点的时候,复制过程与该节点的 oplog 都将延时。延时节点中的数据集将会比复制集中主节点的数据延后。举个例子,现在是09:52,如果延时节点延后了1小时,那么延时节点的数据集中将不会有08:52之后的操作。cfg = rs.conf()cfg.members[1].priority = 0cfg.members[1].hidden = true#延迟1分钟cfg.members[1].slaveDelay = 60rs.reconfig(cfg)
成员角色Primary:主节点,其接收所有的写请求,然后把修改同步到所有备节点。一个复制集只能有一个主节点,当主节点“挂掉”后,其他节点会重新选举出来一个主节点。Secondary:备节点,与主节点保持同样的数据集。当主节点“挂掉”时,参与竞选主节点。分为以下三个不同类型: Hidden = false:正常的只读节点,是否可选为主,是否可投票,取决于 Priority,Vote 的值; Hidden = true:隐藏节点,对客户端不可见, 可以参与选举,但是 Priority 必须为 0,即不能被提升为主。 由于隐藏节点不会接受业务访问,因此可通过隐藏节点做一些数据备份、离线计算的任务,这并不会影响整个复制集。 Delayed :延迟节点,必须同时具备隐藏节点和Priority0的特性,会延迟一定的时间(SlaveDelay 配置决定)从上游复制增量,常用于快速回滚场景。 Arbiter:仲裁节点,只用于参与选举投票,本身不承载任何数据,只作为投票角色。比如你部署了2个节点的复制集,1个 Primary,1个Secondary,任意节点宕机,复制集将不能提供服务了(无法选出Primary),这时可以给复制集添加⼀个 Arbiter节点,即使有节点宕机,仍能选出Primary。 Arbiter本身不存储数据,是非常轻量级的服务,当复制集成员为偶数时,最好加入⼀个Arbiter节点,以提升复制集可用性。
移除复制集节点使用 rs.remove() 来移除节点# 1.关闭节点实例# 2.连接主节点,执行下面命令rs.remove(\"ip:port\
更改复制集节点cfg = rs.conf()cfg.members[0].host = \"ip:port\"rs.reconfig(cfg)
复制集成员角色 复制集里面有多个节点,每个节点拥有不同的职责。
当复制集内存活的成员数量不足大多数时,整个复制集将无法选举出主节点,此时无法提供写服务,这些节点都将处于只读状态。此外,如果希望避免平票结果的产生,最好使用奇数个节点成员,比如3个或5个。当然,在MongoDB复制集的实现中,对于平票问题已经提供了解决方案:为选举定时器增加少量的随机时间偏差,这样避免各个节点在同一时刻发起选举,提高成功率。使用仲裁者角色,该角色不做数据复制,也不承担读写业务,仅仅用来投票。
复制集选举MongoDB的复制集选举使用Raft算法(https://raft.github.io/)来实现,选举成功的必要条件是大多数投票节点存活。在具体的实现中,MongoDB对raft协议添加了一些自己的扩展,这包括:支持chainingAllowed链式复制,即备节点不只是从主节点上同步数据,还可以选择一个离自己最近(心跳延时最小)的节点来复制数据。增加了预投票阶段,即preVote,这主要是用来避免网络分区时产生Term(任期)值激增的问题支持投票优先级,如果备节点发现自己的优先级比主节点高,则会主动发起投票并尝试成为新的主节点。一个复制集最多可以有50 个成员,但只有 7 个投票成员。这是因为一旦过多的成员参与数据复制、投票过程,将会带来更多可靠性方面的问题。
一个影响检测机制的因素是心跳,在复制集组建完成之后,各成员节点会开启定时器,持续向其他成员发起心跳,这里涉及的参数为heartbeatIntervalMillis,即心跳间隔时间,默认值是2s。如果心跳成功,则会持续以2s的频率继续发送心跳;如果心跳失败,则会立即重试心跳,一直到心跳恢复成功。另一个重要的因素是选举超时检测,一次心跳检测失败并不会立即触发重新选举。实际上除了心跳,成员节点还会启动一个选举超时检测定时器,该定时器默认以10s的间隔执行,具体可以通过electionTimeoutMillis参数指定:如果心跳响应成功,则取消上一次的electionTimeout调度(保证不会发起选举),并发起新一轮electionTimeout调度。如果心跳响应迟迟不能成功,那么electionTimeout任务被触发,进而导致备节点发起选举并成为新的主节点。
在MongoDB的实现中,选举超时检测的周期要略大于electionTimeoutMillis设定。该周期会加入一个随机偏移量,大约在10~11.5s,如此的设计是为了错开多个备节点主动选举的时间,提升成功率。因此,在electionTimeout任务中触发选举必须要满足以下条件:(1)当前节点是备节点。(2)当前节点具备选举权限。(3)在检测周期内仍然没有与主节点心跳成功。
业务影响评估在复制集发生主备节点切换的情况下,会出现短暂的无主节点阶段,此时无法接受业务写操作。如果是因为主节点故障导致的切换,则对于该节点的所有读写操作都会产生超时。如果使用MongoDB 3.6及以上版本的驱动,则可以通过开启retryWrite来降低影响。# MongoDB Drivers 启用可重试写入mongodb://localhost/?retryWrites=true# mongo shellmongo --retryWrites 如果主节点属于强制掉电,那么整个Failover过程将会变长,很可能需要在Election定时器超时后才被其他节点感知并恢复,这个时间窗口一般会在12s以内。然而实际上,对于业务呼损的考量还应该加上客户端或mongos对于复制集角色的监视和感知行为(真实的情况可能需要长达30s以上)。对于非常重要的业务,建议在业务层面做一些防护策略,比如设计重试机制。
思考:如何优雅的重启复制集?如果想不丢数据重启复制集,更优雅的打开方式应该是这样的:1. 逐个重启复制集里所有的Secondary节点2. 对Primary发送rs.stepDown()命令,等待primary降级为Secondary 3. 重启降级后的Primary
自动故障转移在故障转移场景中,我们所关心的问题是:备节点是怎么感知到主节点已经发生故障的?如何降低故障转移对业务产生的影响?
在复制集架构中,主节点与备节点之间是通过oplog来同步数据的,这里的oplog是一个特殊的固定集合,当主节点上的一个写操作完成后,会向oplog集合写入一条对应的日志,而备节点则通过这个oplog不断拉取到新的日志,在本地进行回放以达到数据同步的目的。
查看oploguse localdb.oplog.rs.find().sort({$natural:-1}).pretty()
ts字段描述了oplog产生的时间戳,可称之为optime。optime是备节点实现增量日志同步的关键,它保证了oplog是节点有序的,其由两部分组成:当前的系统时间,即UNIX时间至现在的秒数,32位。整数计时器,不同时间值会将计数器进行重置,32位。optime属于BSON的Timestamp类型,这个类型一般在MongoDB内部使用。既然oplog保证了节点级有序,那么备节点便可以通过轮询的方式进行拉取,这里会用到可持续追踪的游标(tailable cursor)技术。
每个备节点都分别维护了自己的一个offset,也就是从主节点拉取的最后一条日志的optime,在执行同步时就通过这个optime向主节点的oplog集合发起查询。为了避免不停地发起新的查询链接,在启动第一次查询后可以将cursor挂住(通过将cursor设置为tailable)。这样只要oplog中产生了新的记录,备节点就能使用同样的请求通道获得这些数据。tailable cursor只有在查询的集合为固定集合时才允许开启。
oplog的写入被放大,导致同步追不上——大数组更新当数组非常大时,对数组的一个小更新,可能就需要把整个数组的内容记录到oplog里,我遇到一个实际的生产环境案例,用户的文档内包含一个很大的数组字段,1000个元素总大小在64KB左右,这个数组里的元素按时间反序存储,新插入的元素会放到数组的最前面($position: 0),然后保留数组的前1000个元素($slice: 1000)。上述场景导致,Primary上的每次往数组里插入一个新元素(请求大概几百字节),oplog里就要记录整个数组的内容,Secondary同步时会拉取oplog并重放,Primary到Secondary同步oplog的流量是客户端到Primary网络流量的上百倍,导致主备间网卡流量跑满,而且由于oplog的量太大,旧的内容很快被删除掉,最终导致Secondary追不上,转换为RECOVERING状态。在文档里使用数组时,一定得注意上述问题,避免数组的更新导致同步开销被无限放大的问题。使用数组时,尽量注意:数组的元素个数不要太多,总的大小也不要太大尽量避免对数组进行更新操作如果一定要更新,尽量只在尾部插入元素,复杂的逻辑可以考虑在业务层面上来支持
复制延迟由于oplog集合是有固定大小的,因此存放在里面的oplog随时可能会被新的记录冲掉。如果备节点的复制不够快,就无法跟上主节点的步伐,从而产生复制延迟(replication lag)问题。这是不容忽视的,一旦备节点的延迟过大,则随时会发生复制断裂的风险,这意味着备节点的optime(最新一条同步记录)已经被主节点老化掉,于是备节点将无法继续进行数据同步。为了尽量避免复制延迟带来的风险,我们可以采取一些措施,比如:增加oplog的容量大小,并保持对复制窗口的监视。通过一些扩展手段降低主节点的写入速度。优化主备节点之间的网络。避免字段使用太大的数组(可能导致oplog膨胀)。
数据回滚由于复制延迟是不可避免的,这意味着主备节点之间的数据无法保持绝对的同步。当复制集中的主节点宕机时,备节点会重新选举成为新的主节点。那么,当旧的主节点重新加入时,必须回滚掉之前的一些“脏日志数据”,以保证数据集与新的主节点一致。主备复制集合的差距越大,发生大量数据回滚的风险就越高。对于写入的业务数据来说,如果已经被复制到了复制集的大多数节点,则可以避免被回滚的风险。应用上可以通过设定更高的写入级别(writeConcern:majority)来保证数据的持久性。这些由旧主节点回滚的数据会被写到单独的rollback目录下,必要的情况下仍然可以恢复这些数据。当rollback发生时,MongoDB将把rollback的数据以BSON格式存放到dbpath路径下rollback文件夹中,BSON文件的命名格式如下:<database>.<collection>.<timestamp>.bsonmongorestore --host 192.168.192:27018 --db test --collection emp -ufox -pfox --authenticationDatabase=admin rollback/emp_rollback.bson
同步源选择MongoDB是允许通过备节点进行复制的,这会发生在以下的情况中:在settings.chainingAllowed开启的情况下,备节点自动选择一个最近的节点(ping命令时延最小)进行同步。settings.chainingAllowed选项默认是开启的,也就是说默认情况下备节点并不一定会选择主节点进行同步,这个副作用就是会带来延迟的增加,你可以通过下面的操作进行关闭:cfg = rs.config()cfg.settings.chainingAllowed = falsers.reconfig(cfg)使用replSetSyncFrom命令临时更改当前节点的同步源,比如在初始化同步时将同步源指向备节点来降低对主节点的影响。db.adminCommand( { replSetSyncFrom: \"hostname:port\" })
什么是oplogMongoDB oplog 是 Local 库下的一个集合,用来保存写操作所产生的增量日志(类似于 MySQL 中 的 Binlog)。它是一个 Capped Collection(固定集合),即超出配置的最大值后,会自动删除最老的历史数据,MongoDB 针对 oplog 的删除有特殊优化,以提升删除效率。主节点产生新的 oplog Entry,从节点通过复制 oplog 并应用来保持和主节点的状态一致;
复制集数据同步机制
复制集高可用
为什么要使用分片?MongoDB复制集实现了数据的多副本复制及高可用,但是一个复制集能承载的容量和负载是有限的。在你遇到下面的场景时,就需要考虑使用分片了:存储容量需求超出单机的磁盘容量。活跃的数据集超出单机内存容量,导致很多请求都要从磁盘读取数据,影响性能。写IOPS超出单个MongoDB节点的写服务能力。垂直扩容(Scale Up) VS 水平扩容(Scale Out): 垂直扩容 : 用更好的服务器,提高 CPU 处理核数、内存数、带宽等 水平扩容 : 将任务分配到多台计算机上
MongoDB 分片集群架构MongoDB 分片集群(Sharded Cluster)是对数据进行水平扩展的一种方式。MongoDB 使用 分片集群来支持大数据集和高吞吐量的业务场景。在分片模式下,存储不同的切片数据的节点被称为分片节点,一个分片集群内包含了多个分片节点。当然,除了分片节点,集群中还需要一些配置节点、路由节点,以保证分片机制的正常运作。
核心概念数据分片:分片用于存储真正的数据,并提供最终的数据读写访问。分片仅仅是一个逻辑的概念,它可以是一个单独的mongod实例,也可以是一个复制集。图中的Shard1、Shard2都是一个复制集分片。在生产环境中也一般会使用复制集的方式,这是为了防止数据节点出现单点故障。配置服务器(Config Server):配置服务器包含多个节点,并组成一个复制集结构,对应于图中的ConfigReplSet。配置复制集中保存了整个分片集群中的元数据,其中包含各个集合的分片策略,以及分片的路由表等。查询路由(mongos):mongos是分片集群的访问入口,其本身并不持久化数据。mongos启动后,会从配置服务器中加载元数据。之后mongos开始提供访问服务,并将用户的请求正确路由到对应的分片。在分片集群中可以部署多个mongos以分担客户端请求的压力。
使用分片集群为了使集合支持分片,需要先开启database的分片功能sh.enableSharding(\"shop\")执行shardCollection命令,对集合执行分片初始化sh.shardCollection(\"shop.product\
向分片集合写入数据向shop.product集合写入一批数据db=db.getSiblingDB(\"shop\");var count=0;for(var i=0;i<1000;i++){ var p=[]; for(var j=0;j<100;j++){ p.push({ \"productId\":\"P-\"+i+\"-\
查询数据的分布db.product.getShardDistribution()
环境搭建分片集群搭建 http://note.youdao.com/noteshare?id=fa7b18a78fa8a4d1961729b043388b57&sub=B8435BD62E634993AD05F910880F0630使用mtools搭建分片集群 http://note.youdao.com/noteshare?id=3c02251c8b4a8bfc98ab392146aa8222&sub=9E0834FE787F413E8EBA774596AB3999搭建视频:https://vip.tulingxueyuan.cn/detail/p_622d92aee4b066e9608ee2c9/6
分片简介分片(shard)是指在将数据进行水平切分之后,将其存储到多个不同的服务器节点上的一种扩展方式。分片在概念上非常类似于应用开发中的“水平分表”。不同的点在于,MongoDB本身就自带了分片管理的能力,对于开发者来说可以做到开箱即用。
假设这个集合大小是1TB,那么拆分到4个分片上之后,每个分片存储256GB的数据。这个当然是最理想化的场景,实质上很难做到如此绝对的平衡。一个集合在拆分后如何存储、读写,与该集合的分片策略设定是息息相关的。在了解分片策略之前,我们先来介绍一下chunk。
chunk所描述的是范围区间,例如,db.users使用了userId作为分片键,那么chunk就是userId的各个值(或哈希值)的连续区间。集群在操作分片集合时,会根据分片键找到对应的chunk,并向该chunk所在的分片发起操作请求,而chunk的分布在一定程度上会影响数据的读写路径,这由以下两点决定:chunk的切分方式,决定如何找到数据所在的chunkchunk的分布状态,决定如何找到chunk所在的分片
什么是chunkchunk的意思是数据块,一个chunk代表了集合中的“一段数据”,例如,用户集合(db.users)在切分成多个chunk之后如图所示:
范围分片能很好地满足范围查询的需求,比如想查询x的值在[-30,10]之间的所有文档,这时mongos直接将请求定位到chunk2所在的分片服务器,就能查询出所有符合条件的文档。范围分片的缺点在于,如果Shard Key有明显递增(或者递减)趋势,则新插入的文档会分布到同一个chunk,此时写压力会集中到一个节点,从而导致单点的性能瓶颈。一些常见的导致递增的Key如下:时间值。ObjectId,自动生成的_id由时间、计数器组成。UUID,包含系统时间、时钟序列。自增整数序列。
哈希分片与范围分片是互补的,由于哈希算法保证了随机性,所以文档可以更加离散地分布到多个chunk上,这避免了集中写问题。然而,在执行一些范围查询时,哈希分片并不是高效的。因为所有的范围查询都必然导致对所有chunk进行检索,如果集群有10个分片,那么mongos将需要对10个分片分发查询请求。哈希分片与范围分片的另一个区别是,哈希分片只能选择单个字段,而范围分片允许采用组合式的多字段作为分片键。
哈希分片仅支持单个字段的哈希分片:{ x : \"hashed\
哈希分片(hash sharding)哈希分片会先事先根据分片键计算出一个新的哈希值(64位整数),再根据哈希值按照范围分片的策略进行chunk的切分。适用于日志,物联网等高并发场景。
分片算法chunk切分是根据分片策略进行实施的,分片策略的内容包括分片键和分片算法。当前,MongoDB支持两种分片算法:
分片标签MongoDB允许通过为分片添加标签(tag)的方式来控制数据分发。一个标签可以关联到多个分片区间(TagRange)。均衡器会优先考虑chunk是否正处于某个分片区间上(被完全包含),如果是则会将chunk迁移到分片区间所关联的分片,否则按一般情况处理。分片标签适用于一些特定的场景。例如,集群中可能同时存在OLTP和OLAP处理,一些系统日志的重要性相对较低,而且主要以少量的统计分析为主。为了便于单独扩展,我们可能希望将日志与实时类的业务数据分开,此时就可以使用标签。为了让分片拥有指定的标签,需执行addShardTag命令sh.addShardTag(\"shard01\
分片键(ShardKey)的选择在选择分片键时,需要根据业务的需求及范围分片、哈希分片的不同特点进行权衡。一般来说,在设计分片键时需要考虑的因素包括:分片键的基数(cardinality),取值基数越大越有利于扩展。 以性别作为分片键 :数据最多被拆分为 2 份 以月份作为分片键 :数据最多被拆分为 12 份 分片键的取值分布应该尽可能均匀。业务读写模式,尽可能分散写压力,而读操作尽可能来自一个或少量的分片。分片键应该能适应大部分的业务操作。
分片键(ShardKey)的约束 ShardKey 必须是一个索引。非空集合须在 ShardCollection 前创建索引;空集合 ShardCollection 自动创建索引 4.4 版本之前: ShardKey 大小不能超过 512 Bytes; 仅支持单字段的哈希分片键; Document 中必须包含 ShardKey; ShardKey 包含的 Field 不可以修改。 4.4 版本之后: ShardKey 大小无限制; 支持复合哈希分片键; Document 中可以不包含 ShardKey,插入时被当 做 Null 处理; 为 ShardKey 添加后缀 refineCollectionShardKey 命令,可以修改 ShardKey 包含的 Field; 而在 4.2 版本之前,ShardKey 对应的值不可以修改;4.2 版本之后,如果 ShardKey 为非_ID 字段, 那么可以修改 ShardKey 对应的值。
分片策略通过分片功能,可以将一个非常大的集合分散存储到不同的分片上,如图:
手动均衡一种做法是,可以在初始化集合时预分配一定数量的chunk(仅适用于哈希分片),比如给10个分片分配1000个chunk,那么每个分片拥有100个chunk。另一种做法则是,可以通过splitAt、moveChunk命令进行手动切分、迁移。
自动均衡开启MongoDB集群的自动均衡功能。均衡器会在后台对各分片的chunk进行监控,一旦发现了不均衡状态就会自动进行chunk的搬迁以达到均衡。其中,chunk不均衡通常来自于两方面的因素:一方面,在没有人工干预的情况下,chunk会持续增长并产生分裂(split),而不断分裂的结果就会出现数量上的不均衡;另一方面,在动态增加分片服务器时,也会出现不均衡的情况。自动均衡是开箱即用的,可以极大简化集群的管理工作。
均衡的方式一种理想的情况是,所有加入的分片都发挥了相当的作用,包括提供更大的存储容量,以及读写访问性能。因此,为了保证分片集群的水平扩展能力,业务数据应当尽可能地保持均匀分布。这里的均匀性包含以下两个方面:所有的数据应均匀地分布于不同的chunk上。每个分片上的chunk数量尽可能是相近的。其中,第1点由业务场景和分片策略来决定,而关于第2点,我们有以下两种选择:
chunk分裂是基于分片键进行的,如果分片键的基数太小,则可能因为无法分裂而会出现jumbo chunk(超大块)的问题。例如,对db.users使用gender(性别)作为分片键,由于同一种性别的用户数可能达到数千万,分裂程序并不知道如何对分片键(gender)的一个单值进行切分,因此最终导致在一个chunk上集中存储了大量的user记录(总大小超过64MB)。jumbo chunk对水平扩展有负面作用,该情况不利于数据的均衡,业务上应尽可能避免。一些写入压力过大的情况可能会导致chunk多次失败(split),最终当chunk中的文档数大于1.3×avgObjectSize时会导致无法迁移。此外在一些老版本中,如果chunk中的文档数超过250000个,也会导致无法迁移。
chunk分裂在默认情况下,一个chunk的大小为64MB,该参数由配置的chunksize参数指定。如果持续地向该chunk写入数据,并导致数据量超过了chunk大小,则MongoDB会自动进行分裂,将该chunk切分为两个相同大小的chunk。
流程说明:分片shard0在持续的业务写入压力下,产生了chunk分裂。分片服务器通知Config Server进行元数据更新。Config Server的自动均衡器对chunk分布进行检查,发现shard0和shard1的chunk数差异达到了阈值,向shard0下发moveChunk命令以执行chunk迁移。shard0执行指令,将指定数据块复制到shard1。该阶段会完成索引、chunk数据的复制,而且在整个过程中业务侧对数据的操作仍然会指向shard0;所以,在第一轮复制完毕之后,目标shard1会向shard0确认是否还存在增量更新的数据,如果存在则继续复制。 shard0完成迁移后发送通知,此时Config Server开始更新元数据库,将chunk的位置更新为目标shard1。在更新完元数据库后并确保没有关联cursor的情况下,shard0会删除被迁移的chunk副本。Config Server通知mongos服务器更新路由表。此时,新的业务请求将被路由到shard1。
迁移阈值均衡器对于数据的“不均衡状态”判定是根据两个分片上的chunk个数差异来进行的chunk个数迁移阈值chunk个数 迁移阈值少于20 220~79 480及以上 8
数据均衡带来的问题数据均衡会影响性能,在分片间进行数据块的迁移是一个“繁重”的工作,很容易带来磁盘I/O使用率飙升,或业务时延陡增等一些问题。因此,建议尽可能提升磁盘能力,如使用SSD。除此之外,我们还可以将数据均衡的窗口对齐到业务的低峰期以降低影响。登录mongos,在config数据库上更新配置,代码如下:use configsh.setBalancerState(true)db.settings.update( {_id:\"balancer\
自动均衡MongoDB的数据均衡器运行于Primary Config Server(配置服务器的主节点)上,而该节点也同时会控制chunk数据的搬迁流程。
数据均衡
分片集群架构
MongoDB复制集
事务简介事务(transaction)是传统数据库所具备的一项基本能力,其根本目的是为数据的可靠性与一致性提供保障。而在通常的实现中,事务包含了一个系列的数据库读写操作,这些操作要么全部完成,要么全部撤销。例如,在电子商城场景中,当顾客下单购买某件商品时,除了生成订单,还应该同时扣减商品的库存,这些操作应该被作为一个整体的执行单元进行处理,否则就会产生不一致的情况。数据库事务需要包含4个基本特性,即常说的ACID,具体如下:原子性(atomicity):事务作为一个整体被执行,包含在其中的对数据库的操作要么全部被执行,要么都不执行。 一致性(consistency):事务应确保数据库的状态从一个一致状态转变为另一个一致状态。一致状态的含义是数据库中的数据应满足完整性约束。隔离性(isolation):多个事务并发执行时,一个事务的执行不应影响其他事务的执行。持久性(durability):已被提交的事务对数据库的修改应该是永久性的。
MongoDB多文档事务在MongoDB中,对单个文档的操作是原子的。由于可以在单个文档结构中使用内嵌文档和数组来获得数据之间的关系,而不必跨多个文档和集合进行范式化,所以这种单文档原子性避免了许多实际场景中对多文档事务的需求。对于那些需要对多个文档(在单个或多个集合中)进行原子性读写的场景,MongoDB支持多文档事务。而使用分布式事务,事务可以跨多个操作、集合、数据库、文档和分片使用。MongoDB 虽然已经在 4.2 开始全面支持了多文档事务,但并不代表大家应该毫无节制地使用它。相反,对事务的使用原则应该是:能不用尽量不用。 通过合理地设计文档模型,可以规避绝大部分使用事务的必要性。使用事务的原则:无论何时,事务的使用总是能避免则避免; 模型设计先于事务,尽可能用模型设计规避事务;不要使用过大的事务(尽量控制在 1000 个文档更新以内); 当必须使用事务时,尽可能让涉及事务的文档分布在同一个分片上,这将有效地提高效率;
w: 数据写入到number个节点才向用客户端确认{w: 0} 对客户端的写入不需要发送任何确认,适用于性能要求高,但不关注正确性的场景{w: 1} 默认的writeConcern,数据写入到Primary就向客户端发送确认{w: “majority”} 数据写入到副本集大多数成员后向客户端发送确认,适用于对数据安全性要求比较高的场景,该选项会降低写入性能
j: 写入操作的journal持久化后才向客户端确认默认为{j: false},如果要求Primary写入持久化了才向客户端确认,则指定该选项为truewtimeout: 写入超时时间,仅w的值大于1时有效。
wtimeout: 写入超时时间,仅w的值大于1时有效。当指定{w: }时,数据需要成功写入number个节点才算成功,如果写入过程中有节点故障,可能导致这个条件一直不能满足,从而一直不能向客户端发送确认结果,针对这种情况,客户端可设置wtimeout选项来指定超时时间,当写入过程持续超过该时间仍未结束,则认为写入失败。
注意事项 虽然多于半数的 writeConcern 都是安全的,但通常只会设置 majority,因为这是等待写入延迟时间最短的选择; 不要设置 writeConcern 等于总节点数,因为一旦有一个节点故障,所有写操作都将失败; writeConcern 虽然会增加写操作延迟时间,但并不会显著增加集群压力,因此无论是否等待,写操作最终都会复制到所有节点上。设置 writeConcern 只是让写操作等待复制后再返回而已;应对重要数据应用 {w: “majority”},普通数据可以应用 {w: 1} 以确保最佳性能。
测试包含延迟节点的3节点pss复制集db.user.insertOne({name:\"李四\
readPreference 场景举例用户下订单后马上将用户转到订单详情页——primary/primaryPreferred。因为此时从节点可能还没复制到新订单;用户查询自己下过的订单——secondary/secondaryPreferred。查询历史订单对 时效性通常没有太高要求; 生成报表——secondary。报表对时效性要求不高,但资源需求大,可以在从节点单独处理,避免对线上用户造成影响; 将用户上传的图片分发到全世界,让各地用户能够就近读取——nearest。每个地区的应用选择最近的节点读取数据。
# 为复制集节点添加标签conf = rs.conf()conf.members[1].tags = { purpose: \"online\"}conf.members[4].tags = { purpose: \"analyse\"}rs.reconfig(conf)#查询db.collection.find({}).readPref( \"secondary\
注意事项指定 readPreference 时也应注意高可用问题。例如将 readPreference 指定 primary,则发生故障转移不存在 primary 期间将没有节点可读。如果业务允许,则应选择 primaryPreferred;使用 Tag 时也会遇到同样的问题,如果只有一个节点拥有一个特定 Tag,则在这个节点失效时将无节点可读。这在有时候是期望的结果,有时候不是。例如: 如果报表使用的节点失效,即使不生成报表,通常也不希望将报表负载转移到其他节点上,此时只有一个节点有报表 Tag 是合理的选择; 如果线上节点失效,通常希望有替代节点,所以应该保持多个节点有同样的 Tag;Tag 有时需要与优先级、选举权综合考虑。例如做报表的节点通常不会希望它成为主节点,则优先级应为 0。
扩展:TagreadPreference 只能控制使用一类节点。Tag 则可以将节点选择控制到一个或几个节点。考虑以下场景:一个 5 个节点的复制集;3 个节点硬件较好,专用于服务线上客户;2 个节点硬件较差,专用于生成报表;可以使用 Tag 来达到这样的控制目的:为 3 个较好的节点打上 {purpose: \"online\"};为 2 个较差的节点打上 {purpose: \"analyse\"};在线应用读取时指定 online,报表读取时指定 analyse。
readPreferencereadPreference决定使用哪一个节点来满足正在发起的读请求。可选值包括:primary: 只选择主节点,默认模式; primaryPreferred:优先选择主节点,如果主节点不可用则选择从节点; secondary:只选择从节点; secondaryPreferred:优先选择从节点, 如果从节点不可用则选择主节点; nearest:根据客户端对节点的 Ping 值判断节点的远近,选择从最近的节点读取。合理的 ReadPreference 可以极大地扩展复制集的读性能,降低访问延迟。
注意事项:虽然看上去总是应该选择 local,但毕竟对结果集进行过滤会造成额外消耗。在一些无关紧要的场景(例如统计)下,也可以考虑 available;MongoDB <=3.6 不支持对从节点使用 {readConcern: \"local\"};从主节点读取数据时默认 readConcern 是 local,从从节点读取数据时默认readConcern 是 available(向前兼容原因)。
readConcern: local 和 available在复制集中 local 和 available 是没有区别的,两者的区别主要体现在分片集上。考虑以下场景:一个 chunk x 正在从 shard1 向 shard2 迁移;整个迁移过程中 chunk x 中的部分数据会在 shard1 和 shard2 中同时存在,但源分片 shard1仍然是chunk x 的负责方:所有对 chunk x 的读写操作仍然进入 shard1;config 中记录的信息 chunk x 仍然属于 shard1;此时如果读 shard2,则会体现出 local 和 available 的区别:local:只取应该由 shard2 负责的数据(不包括 x);available:shard2 上有什么就读什么(包括 x);
readConcern: majority只读取大多数据节点上都提交了的数据。如何实现?节点上维护多个 x 版本(MVCC 机制),MongoDB 通过维护多个快照来链接不同的版本:每个被大多数节点确认过的版本都将是一个快照;快照持续到没有人使用为止才被删除;
主节点测试结果:
在某一个从节点上执行 db.fsyncUnlock(),从节点测试结果:
结论:使用 local 参数,则可以直接查询到写入数据使用 majority,只能查询到已经被多数节点确认过的数据update 与 remove 与上同理。
测试readConcern: majority vs local
MongoDB 中的回滚:写操作到达大多数节点之前都是不安全的,一旦主节点崩溃,而从节点还没复制到该次操作,刚才的写操作就丢失了;把一次写操作视为一个事务,从事务的角度,可以认为事务被回滚了。所以从分布式系统的角度来看,事务的提交被提升到了分布式集群的多个节点级别的“提交”,而不再是单个节点上的“提交”。在可能发生回滚的前提下考虑脏读问题:如果在一次写操作到达大多数节点前读取了这个写操作,然后因为系统故障该操作回滚了,则发生了脏读问题;使用 {readConcern: \"majority\"} 可以有效避免脏读
如何安全的读写分离考虑如下场景: 向主节点写入一条数据;立即从从节点读取这条数据。思考: 如何保证自己能够读到刚刚写入的数据?
readConcern: majority 与脏读
readConcern: linearizable 只读取大多数节点确认过的数据。和 majority 最大差别是保证绝对的操作线性顺序 在写操作自然时间后面的发生的读,一定可以读到之前的写 只对读取单个文档时有效; 可能导致非常慢的读,因此总是建议配合使用 maxTimeMS;
readConcern: snapshot {readConcern: “snapshot”} 只在多文档事务中生效。将一个事务的 readConcern 设置为 snapshot,将保证在事务中的读: 不出现脏读;不出现不可重复读;不出现幻读。 因为所有的读都将使用同一个快照,直到事务提交为止该快照才被释放。
小结 available:读取所有可用的数据 local:读取所有可用且属于当前分片的数据,默认设置 majority:数据读一致性的充分保证,可能你最需要关注的 linearizable:增强处理 majority 情况下主节点失联时候的例外情况 snapshot:最高隔离级别,接近于关系型数据库的Serializable
readConcern在 readPreference 选择了指定的节点后,readConcern 决定这个节点上的数据哪些是可读的,类似于关系数据库的隔离级别。可选值包括:available:读取所有可用的数据;local:读取所有可用且属于当前分片的数据;majority:读取在大多数节点上提交完成的数据;linearizable:可线性化读取文档,仅支持从主节点读;snapshot:读取最近快照中的数据,仅可用于多文档事务;
如果事务内使用 {readConcern: “snapshot”},则可以达到可重复读 Repeatable Readvar session = db.getMongo().startSession()session.startTransaction({ readConcern: {level: \"snapshot\
在执行事务的过程中,如果操作太多,或者存在一些长时间的等待,则可能会产生如下异常:
原因在于,默认情况下MongoDB会为每个事务设置1分钟的超时时间,如果在该时间内没有提交,就会强制将其终止。该超时时间可以通过transactionLifetimeLimitSecond变量设定。
事务超时
写冲突测试开3个 mongo shell 均执行下述语句var session = db.getMongo().startSession()session.startTransaction()var coll = session.getDatabase('test').getCollection(\"tx\
MongoDB 的事务错误处理机制不同于关系数据库: 当一个事务开始后,如果事务要修改的文档在事务外部被修改过,则事务修改这个 文档时会触发 Abort 错误,因为此时的修改冲突了。 这种情况下,只需要简单地重做事务就可以了; 如果一个事务已经开始修改一个文档,在事务以外尝试修改同一个文档,则事务以外的修改会等待事务完成才能继续进行。
注意事项 可以实现和关系型数据库类似的事务场景 必须使用与 MongoDB 4.2 兼容的驱动; 事务默认必须在 60 秒(可调)内完成,否则将被取消; 涉及事务的分片不能使用仲裁节点; 事务会影响 chunk 迁移效率。正在迁移的 chunk 也可能造成事务提交失败(重试 即可);多文档事务中的读操作必须使用主节点读; readConcern 只应该在事务级别设置,不能设置在每次读写操作上。
事务写机制
多文档事务
MongoDB开发规范(1)命名原则。数据库、集合命名需要简单易懂,数据库名使用小写字符,集合名称使用统一命名风格,可以统一大小写或使用驼峰式命名。数据库名和集合名称均不能超过64个字符。(2)集合设计。对少量数据的包含关系,使用嵌套模式有利于读性能和保证原子性的写入。对于复杂的关联关系,以及后期可能发生演进变化的情况,建议使用引用模式。(3)文档设计。避免使用大文档,MongoDB的文档最大不能超过16MB。如果使用了内嵌的数组对象或子文档,应该保证内嵌数据不会无限制地增长。在文档结构上,尽可能减少字段名的长度,MongoDB会保存文档中的字段名,因此字段名称会影响整个集合的大小以及内存的需求。一般建议将字段名称控制在32个字符以内。(4)索引设计。在必要时使用索引加速查询。避免建立过多的索引,单个集合建议不超过10个索引。MongoDB对集合的写入操作很可能也会触发索引的写入,从而触发更多的I/O操作。无效的索引会导致内存空间的浪费,因此有必要对索引进行审视,及时清理不使用或不合理的索引。遵循索引优化原则,如覆盖索引、优先前缀匹配等,使用explain命令分析索引性能。(5)分片设计。对可能出现快速增长或读写压力较大的业务表考虑分片。分片键的设计满足均衡分布的目标,业务上尽量避免广播查询。应尽早确定分片策略,最好在集合达到256GB之前就进行分片。如果集合中存在唯一性索引,则应该确保该索引覆盖分片键,避免冲突。为了降低风险,单个分片的数据集合大小建议不超过2TB。(6)升级设计。应用上需支持对旧版本数据的兼容性,在添加唯一性约束索引之前,对数据表进行检查并及时清理冗余的数据。新增、修改数据库对象等操作需要经过评审,并保持对数据字典进行更新。(7)考虑数据老化问题,要及时清理无效、过期的数据,优先考虑为系统日志、历史数据表添加合理的老化策略。(8)数据一致性方面,非关键业务使用默认的WriteConcern:1(更高性能写入);对于关键业务类,使用WriteConcern:majority保证一致性(性能下降)。如果业务上严格不允许脏读,则使用ReadConcern:majority选项。(9)使用update、findAndModify对数据进行修改时,如果设置了upsert:true,则必须使用唯一性索引避免产生重复数据。(10)业务上尽量避免短连接,使用官方最新驱动的连接池实现,控制客户端连接池的大小,最大值建议不超过200。(11)对大量数据写入使用Bulk Write批量化API,建议使用无序批次更新。(12)优先使用单文档事务保证原子性,如果需要使用多文档事务,则必须保证事务尽可能小,一个事务的执行时间最长不能超过60s。(13)在条件允许的情况下,利用读写分离降低主节点压力。对于一些统计分析类的查询操作,可优先从节点上执行。(14)考虑业务数据的隔离,例如将配置数据、历史数据存放到不同的数据库中。微服务之间使用单独的数据库,尽量避免跨库访问。(15)维护数据字典文档并保持更新,提前按不同的业务进行数据容量的规划。
影响MongoDB性能的因素https://www.processon.com/view/link/6239daa307912906f511b348
MongoDB建模小案例分析
MongoDB性能监控工具
记一次 MongoDB 占用 CPU 过高问题的排查
MongoDB线上案例:一个参数提升16倍写入速度
建模调优
mongodb数据库
TCP/IP网络传输中的数据每个分层中,都会对所发送的数据附加一个首部,在这个首部中包含了该层必要的信息,如发送的目标地址以及协议相关信息。通常,为协议提供的信息为包首部,所要发送的内容为数据。在下一层的角度看,从上一层收到的包全部都被认为是本层的数据。网络中传输的数据包由两部分组成:一部分是协议所要用到的首部,另一部分是上一层传过来的数据。首部的结构由协议的具体规范详细定义。在数据包的首部,明确标明了协议应该如何读取数据。反过来说,看到首部,也就能够了解该协议必要的信息以及所要处理的数据。我们用用户A发送,用户B接受来说说明:
TCP的三次握手的漏洞-SYN洪泛攻击但是在TCP三次握手中是有一个缺陷,被称为SYN洪泛攻击。三次握手中有一个第二次握手,服务端向客户端应答请求,应答请求是需要客户端IP的,而且因为握手过程没有完成,操作系统使用队列维持这个状态(Linux 2.2以后,这个队列大小参数可以通过/proc/sys/net/ipv4/tcp_max_syn_backlog设置)。于是攻击者就伪造这个IP,往服务器端狂发送第一次握手的内容,当然第一次握手中的客户端IP地址是伪造的,从而服务端忙于进行第二次握手,但是第二次握手是不会有应答的,所以导致服务器队列满,而拒绝连接。面对这种攻击,有以下的解决方案,最好的方案是防火墙。无效连接监视释放这种方法不停监视所有的连接,包括三次握手的,还有握手一次的,反正是所有的,当达到一定(与)阈值时拆除这些连接,从而释放系统资源。这种方法对于所有的连接一视同仁,不管是正常的还是攻击的,所以这种方式不推荐。延缓TCB分配方法一般的做完第一次握手之后,服务器就需要为该请求分配一个TCB(连接控制资源),通常这个资源需要200多个字节。延迟TCB的分配,当正常连接建立起来后再分配TCB则可以有效地减轻服务器资源的消耗。使用防火墙防火墙在确认了连接的有效性后,才向内部的服务器(Listener)发起SYN请求,
第一次握手:客户端将请求报文标志位SYN置为1,请求报文的Sequence Number字段(简称seq)中填入一个随机值J,并将该数据包发送给服务器端,客户端进入SYN_SENT状态,等待服务器端确认。第二次握手:服务器端收到数据包后由请求报文标志位SYN=1知道客户端请求建立连接,服务器端将应答报文标志位SYN和ACK都置为1,应答报文的Acknowledgment Number字段(简称ack)中填入ack=J+1,应答报文的seq中填入一个随机值K,并将该数据包发送给客户端以确认连接请求,服务器端进入SYN_RCVD状态。第三次握手:客户端收到应答报文后,检查ack是否为J+1,ACK是否为1,如果正确则将第三个报文标志位ACK置为1,ack=K+1,并将该数据包发送给服务器端,服务器端检查ack是否为K+1,ACK是否为1,如果正确则连接建立成功,客户端和服务器端进入ESTABLISHED状态,完成三次握手,随后客户端与服务器端之间可以开始传输数据了。
为什么TCP的挥手需要四次?TCP是全双工的连接,必须两端同时关闭连接,连接才算真正关闭。 如果一方已经准备关闭写,但是它还可以读另一方发送的数据。发送给FIN结束报文给对方,对方收到后,回复ACK报文。当这方也已经写完了准备关闭,发送FIN报文,对方回复ACK。两端都关闭,TCP连接正常关闭。
为什么需要TIME-WAIT状态?TIME_WAIT状态存在的原因有两点 1、可靠的终止TCP连接。2、保证让迟来的TCP报文有足够的时间被识别并丢弃。根据前面的四次握手的描述,我们知道,客户端收到服务器的连接释放的FIN报文后,必须发出确认。如最后这个ACK确认报文丢失,那么服务器没有收到这个ACK确认报文,就要重发FIN连接释放报文,客户端要在某个状态等待这个FIN连接释放报文段然后回复确认报文段,这样才能可靠的终止TCP连接。在Linux系统上,一个TCP端口不能被同时打开多次,当一个TCP连接处于TIME_WAIT状态时,我们无法使用该链接的端口来建立一个新连接。反过来思考,如果不存在TIME_WAIT状态,则应用程序能过立即建立一个和刚关闭的连接相似的连接(这里的相似,是指他们具有相同的IP地址和端口号)。这个新的、和原来相似的连接被称为原来连接的化身。新的化身可能受到属于原来连接携带应用程序数据的TCP报文段(迟到的报文段),这显然是不该发生的。这是TIME_WAIT状态存在的第二个原因。
TCP四次挥手(分手)四次挥手即终止TCP连接,就是指断开一个TCP连接时,需要客户端和服务端总共发送4个包以确认连接的断开。在socket编程中,这一过程由客户端或服务端任一方执行close来触发。由上图可见,TCP建立一个连接需3个分节,终止一个连接则需4个分节。(1)某个应用进程首先调用close,我们称该端执行主动关闭(active close)。该端的TCP于是发送一个FIN分节,表示数据发送完毕,应用进程进入FIN-WAIT-1(终止等待1)状态。(2)接收到这个FIN的对端执行被动关闭(passive close),发出确认报文。因为FIN的接收意味着接收端应用进程在相应连接上再无额外数据可接收,接收端进入了CLOSE-WAIT(关闭等待)状态,这时候处于半关闭状态,即主动关闭端已经没有数据要发送了,但是被动关闭端若发送数据,主动关闭端依然要接受。这个状态还要持续一段时间,也就是整个CLOSE-WAIT状态持续的时间。主动关闭端收到确认报文后进入FIN-WAIT-2(终止等待2)状态。(3)一段时间后,被动关闭的应用进程将调用close关闭它的套接字。这导致它的TCP也发送一个FIN,表示它也没数据需要发送了。 (4)接收这个最终FIN的原发送端TCP(即执行主动关闭的那一端)确认这个FIN发出一个确认ACK报文,并进入了TIME-WAIT(时间等待)状态。注意此时TCP连接还没有释放,必须经过2∗MSL(最长报文段寿命/最长分节生命期 max segement lifetime,MSL是任何IP数据报能够在因特网中存活的最长时间,任何TCP实现都必须为MSL选择一个值。RFC 1122[Braden 1989]的建议值是2分钟,不过源自Berkelcy的实现传统上改用30秒这个值。这意味着TIME_WAIT状态的持续时间在1分钟到4分钟之间)的时间后,当主动关闭端撤销相应的TCB后,才进入CLOSED状态。(5) 被动关闭端只要收到了客户端发出的确认,立即进入CLOSED状态。同样,撤销TCB后,就结束了这次的TCP连接。可以看到,被动关闭端结束TCP连接的时间要比主动关闭端早一些。既然每个方向都需要一个FIN和一个ACK,因此通常需要4个分节。我们使用限定词“通常”是因为:某些情形下步骤1的FIN随数据一起发送;另外,步骤2和步骤3发送的分节都出自执行被动关闭那-一端,有可能被合并成一个分节。
TCP特性在我们上面的讲述中,存在着客户端和服务端两者角色,在网络通信里是怎么区分的?这个就牵涉到了TCP的相关特性。
UDP单播和广播单播的传输模式,定义为发送消息给一个由唯一的地址所标识的单一的网络目的地。面向连接的协议和无连接协议都支持这种模式。由于通讯不需要连接,所以可以实现广播发送,所谓广播——传输到网络(或者子网)上的所有主机。UDP因为没有TCP等一系列复杂机制,所以使用也非常广泛,使用UDP的服务包括NTP(网络时间协议)和DNS(DNS也使用TCP),包总量较少的通信(DNS、SNMP等);2.视频、音频等多媒体通信(即时通信);3.限定于 LAN 等特定网络中的应用通信;4.DHCP等协议就利用了UDP的广播功能。常用的QQ,就是一个以UDP为主,TCP为辅的通讯协议。
UDT基于UDP的数据传输协议(UDP-based Data Transfer Protocol,简称UDT)是一种互联网数据传输协议。UDT的主要目的是支持高速广域网上的海量数据传输,最典型的例子就是建立在光纤广域网上的网格计算,一些研究所在这样的网络上运行他们的分布式的数据密集程式,例如,远程访问仪器、分布式数据挖掘和高分辨率的多媒体流。
QUICQUIC代表”快速UDP Internet连接”,基于UDP的传输层协议,它本身就是Google尝试将TCP协议重写为一种结合了HTTP/2、TCP、UDP和TLS(用于加密)等多种技术的改进技术。谷歌希望QUIC通信技术逐渐取代TCP和UDP,作为在Internet上移动二进制数据的新选择协议,QUIC 协议的主要目的,是为了整合 TCP 协议的可靠性和 UDP 协议的速度和效率。由于 TCP 是在操作系统内核和中间件固件中实现的,因此对 TCP 进行重大更改几乎是不可能的(TCP 协议栈通常由操作系统实现,如 Linux、Windows 内核或者其他移动设备操作系统。修改 TCP 协议是一项浩大的工程,因为每种设备、系统的实现都需要更新)。但是,由于 QUIC 建立在 UDP 之上,因此没有这种限制。
UDP概述我们已经知道UDP(User Datagram Protocol的简称, 中文名是用户数据报协议)是把数据直接发出去,而不管对方是不是在接收,也不管对方是否能接收的了,也不需要接收方确认,属于不可靠的传输,可能会出现丢包现象,实际应用中要求程序员编程验证。
TCPIP协议
零拷贝(英语: Zero-copy) 技术是指计算机执行操作时,CPU不需要先将数据从某处内存复制到另一个特定区域。这种技术通常用于通过网络传输文件时节省CPU周期和内存带宽。➢零拷贝技术可以减少数据拷贝和共享总线操作的次数,消除传输数据在存储器之间不必要的中间拷贝次数,从而有效地提高数据传输效率➢零拷贝技术减少了用户进程地址空间和内核地址空间之间因为上:下文切换而带来的开销可以看出没有说不需要拷贝,只是说减少冗余[不必要]的拷贝。下面这些组件、框架中均使用了零拷贝技术:Kafka、Netty、Rocketmq、Nginx、Apache。
DMA(Direct Memory Access,直接内存存取) 是所有现代电脑的重要特色,它允许不同速度的硬件装置来沟通,而不需要依赖于CPU 的大量中断负载。DMA控制器,接管了数据读写请求,减少CPU的负担。这样一来,CPU能高效工作了。现代硬盘基本都支持DMA。实际因此IO读取,涉及两个过程:1、DMA等待数据准备好,把磁盘数据读取到操作系统内核缓冲区;2、用户进程,将内核缓冲区的数据copy到用户空间。
传统数据传送机制比如:读取文件,再用socket发送出去,实际经过四次copy。伪码实现如下:buffer = File.read() Socket.send(buffer)1、第一次:将磁盘文件,读取到操作系统内核缓冲区;2、第二次:将内核缓冲区的数据,copy到应用程序的buffer;3、第三步:将application应用程序buffer中的数据,copy到socket网络发送缓冲区(属于操作系统内核的缓冲区);4、第四次:将socket buffer的数据,copy到网卡,由网卡进行网络传输。分析上述的过程,虽然引入DMA来接管CPU的中断请求,但四次copy是存在“不必要的拷贝”的。实际上并不需要第二个和第三个数据副本。应用程序除了缓存数据并将其传输回套接字缓冲区之外什么都不做。相反,数据可以直接从读缓冲区传输到套接字缓冲区。显然,第二次和第三次数据copy 其实在这种场景下没有什么帮助反而带来开销,这也正是零拷贝出现的背景和意义。
Linux的I/O机制与DMA在早期计算机中,用户进程需要读取磁盘数据,需要CPU中断和CPU参与,因此效率比较低,发起IO请求,每次的IO中断,都带来CPU的上下文切换。因此出现了——DMA。
mmap内存映射 硬盘上文件的位置和应用程序缓冲区(application buffers)进行映射(建立一种一一对应关系),由于mmap()将文件直接映射到用户空间,所以实际文件读取时根据这个映射关系,直接将文件从硬盘拷贝到用户空间,只进行了一次数据拷贝,不再有文件内容从硬盘拷贝到内核空间的一个缓冲区。mmap内存映射将会经历:3次拷贝: 1次cpu copy,2次DMA copy;以及4次上下文切换,调用mmap函数2次,write函数2次。
sendfilelinux 2.1支持的sendfile当调用sendfile()时,DMA将磁盘数据复制到kernel buffer,然后将内核中的kernel buffer直接拷贝到socket buffer;但是数据并未被真正复制到socket关联的缓冲区内。取而代之的是,只有记录数据位置和长度的描述符被加入到socket缓冲区中。DMA模块将数据直接从内核缓冲区传递给协议引擎,从而消除了遗留的最后一次复制。但是要注意,这个需要DMA硬件设备支持,如果不支持,CPU就必须介入进行拷贝。一旦数据全都拷贝到socket buffer,sendfile()系统调用将会return、代表数据转化的完成。socket buffer里的数据就能在网络传输了。sendfile会经历:3(2,如果硬件设备支持)次拷贝,1(0,,如果硬件设备支持)次CPU copy, 2次DMA copy;以及2次上下文切换
spliceLinux 从2.6.17 支持splice数据从磁盘读取到OS内核缓冲区后,在内核缓冲区直接可将其转成内核空间其他数据buffer,而不需要拷贝到用户空间。如下图所示,从磁盘读取到内核buffer后,在内核空间直接与socket buffer建立pipe管道。和sendfile()不同的是,splice()不需要硬件支持。注意splice和sendfile的不同,sendfile是DMA硬件设备不支持的情况下将磁盘数据加载到kernel buffer后,需要一次CPU copy,拷贝到socket buffer。而splice是更进一步,连这个CPU copy也不需要了,直接将两个内核空间的buffer进行pipe。splice会经历 2次拷贝: 0次cpu copy 2次DMA copy;以及2次上下文切换
Linux支持的(常见)零拷贝目的:减少IO流程中不必要的拷贝,当然零拷贝需要OS支持,也就是需要kernel暴露api。
NIO提供的内存映射 MappedByteBufferNIO中的FileChannel.map()方法其实就是采用了操作系统中的内存映射方式,底层就是调用Linux mmap()实现的。将内核缓冲区的内存和用户缓冲区的内存做了一个地址映射。这种方式适合读取大文件,同时也能对文件内容进行更改,但是如果其后要通过SocketChannel发送,还是需要CPU进行数据的拷贝。
NIO提供的sendfileJava NIO 中提供的 FileChannel 拥有 transferTo 和 transferFrom 两个方法,可直接把 FileChannel 中的数据拷贝到另外一个 Channel,或者直接把另外一个 Channel 中的数据拷贝到 FileChannel。该接口常被用于高效的网络 / 文件的数据传输和大文件拷贝。在操作系统支持的情况下,通过该方法传输数据并不需要将源数据从内核态拷贝到用户态,再从用户态拷贝到目标通道的内核态,同时也避免了两次用户态和内核态间的上下文切换,也即使用了“零拷贝”,所以其性能一般高于 Java IO 中提供的方法。
Kafka中的零拷贝Kafka两个重要过程都使用了零拷贝技术,且都是操作系统层面的狭义零拷贝,一是Producer生产的数据存到broker,二是 Consumer从broker读取数据。Producer生产的数据持久化到broker,broker里采用mmap文件映射,实现顺序的快速写入;Customer从broker读取数据,broker里采用sendfile,将磁盘文件读到OS内核缓冲区后,直接转到socket buffer进行网络发送。
Netty的零拷贝实现Netty 的零拷贝主要包含三个方面:在网络通信上,Netty 的接收和发送 ByteBuffer 采用 DIRECT BUFFERS,使用堆外直接内存进行 Socket 读写,不需要进行字节缓冲区的二次拷贝。如果使用传统的堆内存(HEAP BUFFERS)进行 Socket 读写,JVM 会将堆内存 Buffer 拷贝一份到直接内存中,然后才写入 Socket 中。相比于堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。在缓存操作上,Netty提供了CompositeByteBuf类,它可以将多个ByteBuf合并为一个逻辑上的ByteBuf,避免了各个ByteBuf之间的拷贝。通过wrap操作,我们可以将byte[]数组、ByteBuf、 ByteBuffer 等包装成一个 Netty ByteBuf对象,进而避免了拷贝操作。ByteBuf支持slice 操作,因此可以将ByteBuf分解为多个共享同一个存储区域的ByteBuf,避免了内存的拷贝。在文件传输上,Netty 的通过FileRegion包装的FileChannel.tranferTo实现文件传输,它可以直接将文件缓冲区的数据发送到目标 Channel,避免了传统通过循环 write 方式导致的内存拷贝问题。
Java生态圈中的零拷贝Linux提供的零拷贝技术 Java并不是全支持,支持2种(内存映射mmap、sendfile);
零拷贝
阻塞IO就是JDK里的BIO编程,IO复用就是JDK里的NIO编程,Linux下异步IO的实现建立在epoll之上,是个伪异步实现,而且相比IO复用,没有体现出性能优势,使用不广。非阻塞IO使用轮询模式,会不断检测是否有数据到达,大量的占用CPU的时间,是绝不被推荐的模型。信号驱动IO需要在网络通信时额外安装信号处理函数,使用也不广泛。
阻塞IO模型
I/O复用模型
比较上面两张图,IO复用需要使用两个系统调用(select和recvfrom),而blocking IO只调用了一个系统调用(recvfrom)。但是,用select的优势在于它可以同时处理多个connection。所以,如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大。select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。
select、poll、epoll的比较select,poll,epoll都是 操作系统实现IO多路复用的机制。 我们知道,I/O多路复用就通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。那么这三种机制有什么区别呢。 1、支持一个进程所能打开的最大连接数select单个进程所能打开的最大连接数有FD_SETSIZE宏定义,其大小是32个整数的大小(在32位的机器上,大小就是32*32,同理64位机器上FD_SETSIZE为32*64),当然我们可以对进行修改,然后重新编译内核,但是性能可能会受到影响。pollpoll本质上和select没有区别,但是它没有最大连接数的限制,原因是它是基于链表来存储的epoll虽然连接数基本上只受限于机器的内存大小2、FD剧增后带来的IO效率问题select因为每次调用时都会对连接进行线性遍历,所以随着FD的增加会造成遍历速度慢的“线性下降性能问题”。poll同上epoll因为epoll内核中实现是根据每个fd上的callback函数来实现的,只有活跃的socket才会主动调用callback,所以在活跃socket较少的情况下,使用epoll没有前面两者的线性下降的性能问题,但是所有socket都很活跃的情况下,可能会有性能问题。3、 消息传递方式select内核需要将消息传递到用户空间,都需要内核拷贝动作poll同上epollepoll通过内核和用户空间共享一块内存来实现的。总结:综上,在选择select,poll,epoll时要根据具体的使用场合以及这三种方式的自身特点。1、表面上看epoll的性能最好,但是在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调。2、select低效是因为每次它都需要轮询。但低效也是相对的,视情况而定,也可通过良好的设计改善。
Linux下的IO复用编程select,poll,epoll都是IO多路复用的机制。I/O多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,并等待读写完成。
Linux 对网络通信的实现
Netty的优势1、API使用简单,开发门槛低;2、功能强大,预置了多种编解码功能,支持多种主流协议;3、定制能力强,可以通过ChannelHandler对通信框架进行灵活地扩展;4、性能高,通过与其他业界主流的NIO框架对比,Netty的综合性能最优;5、成熟、稳定,Netty修复了已经发现的所有JDK NIO BUG,业务开发人员不需要再为NIO的BUG而烦恼;6、社区活跃,版本迭代周期短,发现的BUG可以被及时修复,同时,更多的新功能会加入;7、经历了大规模的商业应用考验,质量得到验证。
Bootstrap、EventLoop(Group) 、ChannelBootstrap是Netty框架的启动类和主入口类,分为客户端类Bootstrap和服务器类ServerBootstrap两种。Channel 是Java NIO 的一个基本构造。它代表一个到实体(如一个硬件设备、一个文件、一个网络套接字或者一个能够执行一个或者多个不同的I/O操作的程序组件)的开放连接,如读操作和写操作目前,可以把Channel 看作是传入(入站)或者传出(出站)数据的载体。因此,它可以被打开或者被关闭,连接或者断开连接。EventLoop暂时可以看成一个线程、EventLoopGroup自然就可以看成线程组。
事件和ChannelHandler、ChannelPipelineNetty 使用不同的事件来通知我们状态的改变或者是操作的状态。这使得我们能够基于已经发生的事件来触发适当的动作。Netty事件是按照它们与入站或出站数据流的相关性进行分类的。可能由入站数据或者相关的状态更改而触发的事件包括:连接已被激活或者连接失活;数据读取;用户事件;错误事件。出站事件是未来将会触发的某个动作的操作结果,这些动作包括:打开或者关闭到远程节点的连接;将数据写到或者冲刷到套接字。每个事件都可以被分发给ChannelHandler 类中的某个用户实现的方法,既然事件分为入站和出站,用来处理事件的ChannelHandler 也被分为可以处理入站事件的Handler和出站事件的Handler,当然有些Handler既可以处理入站也可以处理出站。Netty 提供了大量预定义的可以开箱即用的ChannelHandler 实现,包括用于各种协议(如HTTP 和SSL/TLS)的ChannelHandler。基于Netty的网络应用程序中根据业务需求会使用Netty已经提供的ChannelHandler或者自行开发ChannelHandler,这些ChannelHandler都放在ChannelPipeline中统一管理,事件就会在ChannelPipeline中流动,并被其中一个或者多个ChannelHandler处理。
ChannelFutureNetty 中所有的I/O 操作都是异步的,我们知道“异步的意思就是不需要主动等待结果的返回,而是通过其他手段比如,状态通知,回调函数等”,那就是说至少我们需要一种获得异步执行结果的手段。JDK 预置了interface java.util.concurrent.Future,Future 提供了一种在操作完成时通知应用程序的方式。这个对象可以看作是一个异步操作的结果的占位符;它将在未来的某个时刻完成,并提供对其结果的访问。但是其所提供的实现,只允许手动检查对应的操作是否已经完成,或者一直阻塞直到它完成。这是非常繁琐的,所以Netty提供了它自己的实现ChannelFuture,用于在执行异步操作的时候使用。一般来说,每个Netty 的出站I/O 操作都将返回一个ChannelFuture。
Netty大体流程
服务于Channel 的I/O 和事件的EventLoop 包含在EventLoopGroup 中。异步传输实现只使用了少量的EventLoop(以及和它们相关联的Thread),而且在当前的线程模型中,它们可能会被多个Channel 所共享。这使得可以通过尽可能少量的Thread 来支撑大量的Channel,而不是每个Channel 分配一个Thread。EventLoopGroup 负责为每个新创建的Channel 分配一个EventLoop。在当前实现中,使用顺序循环(round-robin)的方式进行分配以获取一个均衡的分布,并且相同的EventLoop可能会被分配给多个Channel。一旦一个Channel 被分配给一个EventLoop,它将在它的整个生命周期中都使用这个EventLoop(以及相关联的Thread)。
EventLoop和EventLoopGroup回想一下我们在NIO中是如何处理我们关心的事件的?在一个while循环中select出事件,然后依次处理每种事件。我们可以把它称为事件循环,这就是EventLoop。interface io.netty.channel. EventLoop 定义了Netty 的核心抽象,用于处理网络连接的生命周期中所发生的事件。
Channel 的生命周期状态ChannelUnregistered :Channel 已经被创建,但还未注册到EventLoopChannelRegistered :Channel 已经被注册到了EventLoopChannelActive :Channel 处于活动状态(已经连接到它的远程节点)。它现在可以接收和发送数据了ChannelInactive :Channel 没有连接到远程节点
重要Channel 的方法eventLoop: 返回分配给Channel 的EventLooppipeline: 返回Channel 的ChannelPipeline,也就是说每个Channel 都有自己的ChannelPipeline。isActive: 如果Channel 是活动的,则返回true。活动的意义可能依赖于底层的传输。例如,一个Socket 传输一旦连接到了远程节点便是活动的,而一个Datagram 传输一旦被打开便是活动的。localAddress: 返回本地的SokcetAddressremoteAddress: 返回远程的SocketAddresswrite: 将数据写到远程节点,注意,这个写只是写往Netty内部的缓存,还没有真正写往socket。flush: 将之前已写的数据冲刷到底层socket进行传输。writeAndFlush: 一个简便的方法,等同于调用write()并接着调用flush()
Channel 接口基本的I/O 操作(bind()、connect()、read()和write())依赖于底层网络传输所提供的原语。在基于Java 的网络编程中,其基本的构造是类Socket。Netty 的Channel 接口所提供的API,被用于所有的I/O 操作。
ChannelPipeline 接口当Channel 被创建时,它将会被自动地分配一个新的ChannelPipeline,每个Channel 都有自己的ChannelPipeline。这项关联是永久性的。在Netty 组件的生命周期中,这是一项固定的操作,不需要开发人员的任何干预。ChannelPipeline 提供了ChannelHandler 链的容器,并定义了用于在该链上传播入站(也就是从网络到业务处理)和 出站(也就是从业务处理到网络),各种事件流的API,我们代码中的ChannelHandler 都是放在ChannelPipeline 中的。
ChannelHandler 的生命周期在ChannelHandler被添加到ChannelPipeline 中或者被从ChannelPipeline 中移除时会调用下面这些方法。这些方法中的每一个都接受一个ChannelHandlerContext 参数。handlerAdded 当把ChannelHandler 添加到ChannelPipeline 中时被调用handlerRemoved 当从ChannelPipeline 中移除ChannelHandler 时被调用exceptionCaught 当处理过程中在ChannelPipeline 中有错误产生时被调用
入站和出站ChannelHandler 被安装到同一个ChannelPipeline中,ChannelPipeline以双向链表的形式进行维护管理。比如下图,我们在网络上传递的数据,要求加密,但是加密后密文比较大,需要压缩后再传输,而且按照业务要求,需要检查报文中携带的用户信息是否合法,于是我们实现了5个Handler:解压(入)Handler、压缩(出)handler、解密(入) Handler、加密(出) Handler、授权(入) Handler。
ChannelPipeline上的方法既然ChannelPipeline以双向链表的形式进行维护管理Handler,自然也提供了对应的方法在ChannelPipeline中增加或者删除、替换Handler。addFirst、addBefore、addAfter、addLast将一个ChannelHandler 添加到ChannelPipeline 中remove 将一个ChannelHandler 从ChannelPipeline 中移除replace 将ChannelPipeline 中的一个ChannelHandler 替换为另一个ChannelHandlerget 通过类型或者名称返回ChannelHandlercontext 返回和ChannelHandler 绑定的ChannelHandlerContextnames 返回ChannelPipeline 中所有ChannelHandler 的名称ChannelPipeline 的API 公开了用于调用入站和出站操作的附加方法。
ChannelHandlerContext 的APIalloc 返回和这个实例相关联的Channel 所配置的ByteBufAllocatorbind 绑定到给定的SocketAddress,并返回ChannelFuturechannel 返回绑定到这个实例的Channelclose 关闭Channel,并返回ChannelFutureconnect 连接给定的SocketAddress,并返回ChannelFuturederegister 从之前分配的EventExecutor 注销,并返回ChannelFuturedisconnect 从远程节点断开,并返回ChannelFutureexecutor 返回调度事件的EventExecutorfireChannelActive 触发对下一个ChannelInboundHandler 上的channelActive()方法(已连接)的调用fireChannelInactive 触发对下一个ChannelInboundHandler 上的channelInactive()方法(已关闭)的调用fireChannelRead 触发对下一个ChannelInboundHandler 上的channelRead()方法(已接收的消息)的调用fireChannelReadComplete 触发对下一个ChannelInboundHandler 上的channelReadComplete()方法的调用fireChannelRegistered 触发对下一个ChannelInboundHandler 上的fireChannelRegistered()方法的调用fireChannelUnregistered 触发对下一个ChannelInboundHandler 上的fireChannelUnregistered()方法的调用fireChannelWritabilityChanged 触发对下一个ChannelInboundHandler 上的fireChannelWritabilityChanged()方法的调用fireExceptionCaught 触发对下一个ChannelInboundHandler 上的fireExceptionCaught(Throwable)方法的调用fireUserEventTriggered 触发对下一个ChannelInboundHandler 上的fireUserEventTriggered(Object evt)方法的调用handler 返回绑定到这个实例的ChannelHandlerisRemoved 如果所关联的ChannelHandler 已经被从ChannelPipeline中移除则返回truename 返回这个实例的唯一名称pipeline 返回这个实例所关联的ChannelPipelineread 将数据从Channel读取到第一个入站缓冲区;如果读取成功则触发一个channelRead事件,并(在最后一个消息被读取完成后)通知ChannelInboundHandler 的channelReadComplete(ctx)方法write 通过这个实例写入消息并经过ChannelPipelinewriteAndFlush 通过这个实例写入并冲刷消息并经过ChannelPipeline当使用ChannelHandlerContext 的API 的时候,有以下两点:ChannelHandlerContext 和ChannelHandler 之间的关联(绑定)是永远不会改变的,所以缓存对它的引用是安全的;相对于其他类的同名方法,ChannelHandlerContext的方法将产生更短的事件流,应该尽可能地利用这个特性来获得最大的性能。
ChannelHandlerContextChannelHandlerContext 代表了ChannelHandler 和ChannelPipeline 之间的关联,每当有ChannelHandler 添加到ChannelPipeline 中时,都会创建ChannelHandlerContext,为什么需要这个ChannelHandlerContext ?前面我们已经说过,ChannelPipeline以双向链表的形式进行维护管理Handler,毫无疑问,Handler在放入ChannelPipeline的时候必须要有两个指针pre和next来说明它的前一个元素和后一个元素,但是Handler本身来维护这两个指针合适吗?想想我们在使用JDK的LinkedList的时候,我们放入LinkedList的数据是不会带这两个指针的,LinkedList内部会用类Node对我们的数据进行包装,而类Node则带有两个指针pre和next。所以,ChannelHandlerContext 的主要作用就和LinkedList内部的类Node类似。不过ChannelHandlerContext 不仅仅只是个包装类,它还提供了很多的方法,比如让事件从当前ChannelHandler传递给链中的下一个ChannelHandler,还可以被用于获取底层的Channel,还可以用于写出站数据。
ChannelInboundHandler 接口下面列出了接口 ChannelInboundHandler 的生命周期方法。这些方法将会在数据被接收时或者与其对应的Channel 状态发生改变时被调用。正如我们前面所提到的,这些方法和Channel 的生命周期密切相关。channelRegistered 当Channel 已经注册到它的EventLoop 并且能够处理I/O 时被调用channelUnregistered 当Channel 从它的EventLoop 注销并且无法处理任何I/O 时被调用channelActive 当Channel 处于活动状态时被调用;Channel 已经连接/绑定并且已经就绪channelInactive 当Channel 离开活动状态并且不再连接它的远程节点时被调用channelReadComplete 当Channel上的一个读操作完成时被调用channelRead 当从Channel 读取数据时被调用ChannelWritabilityChanged当Channel 的可写状态发生改变时被调用。可以通过调用Channel 的isWritable()方法来检测Channel 的可写性。与可写性相关的阈值可以通过Channel.config().setWriteHighWaterMark()和Channel.config().setWriteLowWaterMark()方法来设置userEventTriggered 当ChannelnboundHandler.fireUserEventTriggered()方法被调用时被调用。
Netty 定义了下面两个重要的ChannelHandler 子接口:ChannelInboundHandler——处理入站数据以及各种状态变化;ChannelOutboundHandler——处理出站数据并且允许拦截所有的操作。
ChannelHandler
ChannelPipeline中的ChannelHandler
ChannelPipeline和ChannelHandlerContext
Channel、EventLoop(Group)和ChannelFutureNetty 网络抽象的代表:Channel—Socket;EventLoop—控制流、多线程处理、并发;ChannelFuture—异步通知。Channel和EventLoop关系如图:从图上我们可以看出Channel需要被注册到某个EventLoop上,在Channel整个生命周期内都由这个EventLoop处理IO事件,也就是说一个Channel和一个EventLoop进行了绑定,但是一个EventLoop可以同时被多个Channel绑定。这一点在“EventLoop和EventLoopGroup”节里也提及过。
ServerBootstrap 将绑定到一个端口,因为服务器必须要监听连接,而Bootstrap 则是由想要连接到远程节点的客户端应用程序所使用的。第二个区别可能更加明显。引导一个客户端只需要一个EventLoopGroup,但是一个ServerBootstrap 则需要两个(也可以是同一个实例)。因为服务器需要两组不同的Channel。第一组将只包含一个ServerChannel,代表服务器自身的已绑定到某个本地端口的正在监听的套接字。而第二组将包含所有已创建的用来处理传入客户端连接(对于每个服务器已经接受的连接都有一个)的Channel。与ServerChannel 相关联的EventLoopGroup 将分配一个负责为传入连接请求创建Channel 的EventLoop。一旦连接被接受,第二个EventLoopGroup 就会给它的Channel分配一个EventLoop。ChannelInitializer
引导Bootstrap因此,有两种类型的引导:一种用于客户端(简单地称为Bootstrap),而另一种(ServerBootstrap)用于服务器。无论你的应用程序使用哪种协议或者处理哪种类型的数据,唯一决定它使用哪种引导类的是它是作为一个客户端还是作为一个服务器。
更具体的原因至少包括:1. 应用程序写入数据的字节大小大于套接字发送缓冲区的大小2. 进行MSS大小的TCP分段。MSS是最大报文段长度的缩写。MSS是TCP报文段中的数据字段的最大长度。数据字段加上TCP首部才等于整个的TCP报文段。所以MSS并不是TCP报文段的最大长度,而是:MSS=TCP报文段长度-TCP首部长度。解决粘包半包
解决粘包半包由于底层的TCP无法理解上层的业务数据,所以在底层是无法保证数据包不被拆分和重组的,这个问题只能通过上层的应用协议栈设计来解决,根据业界的主流协议的解决方案,可以归纳如下。 (1) 在包尾增加分割符,比如回车换行符进行分割,例如FTP协议;参见cn.tuling.nettybasic.splicing.linebase(回车换行符进行分割)和cn.tuling.nettybasic.splicing.delimiter(自定义分割符)下的代码(2)消息定长,例如每个报文的大小为固定长度200字节,如果不够,空位补空格;参见cn.tuling.nettybasic.splicing.fixed下的代码(3)将消息分为消息头和消息体,消息头中包含表示消息总长度(或者消息体长度)的字段,通常设计思路为消息头的第一个字段使用int32来表示消息的总长度,使用LengthFieldBasedFrameDecoder,后面会有详细说明和使用。
解决粘包/半包
Netty 使用和常用组件
Netty网络框架
CAP 理论CAP 理论指出对于一个分布式计算系统来说,不可能同时满足以下三点:一致性:在分布式环境中,一致性是指数据在多个副本之间是否能够保持一致的特性,等同于所有节点访问同一份最新的数据副本。在一致性的需求下,当一个系统在数据一致的状态下执行更新操作后,应该保证系统的数据仍然处于一致的状态。可用性:每次请求都能获取到正确的响应,但是不保证获取的数据为最新数据。分区容错性:分布式系统在遇到任何网络分区故障的时候,仍然需要能够保证对外提供满足一致性和可用性的服务,除非是整个网络环境都发生了故障。一个分布式系统最多只能同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance)这三项中的两项。在这三个基本需求中,最多只能同时满足其中的两项,P 是必须的,因此只能在 CP 和 AP 中选择,zookeeper 保证的是 CP,对比 spring cloud 系统中的注册中心 eureka实现的是 AP。
BASE 理论BASE 是 Basically Available(基本可用)、Soft-state(软状态) 和 Eventually Consistent(最终一致性) 三个短语的缩写。基本可用:在分布式系统出现故障,允许损失部分可用性(服务降级、页面降级)。软状态:允许分布式系统出现中间状态。而且中间状态不影响系统的可用性。这里的中间状态是指不同的 data replication(数据备份节点)之间的数据更新可以出现延时的最终一致性。最终一致性:data replications 经过一段时间达到一致性。BASE 理论是对 CAP 中的一致性和可用性进行一个权衡的结果,理论的核心思想就是:我们无法做到强一致,但每个应用都可以根据自身的业务特点,采用适当的方式来使系统达到最终一致性。
CAP&Base理论
ZooKeeper本质上是一个分布式的小文件存储系统(Zookeeper=文件系统+监听机制)。提供基于类似于文件系统的目录树方式的数据存储,并且可以对树中的节点进行有效管理,从而用来维护和监控存储的数据的状态变化。通过监控这些数据状态的变化,从而可以达到基于数据的集群管理、统一命名服务、分布式配置管理、分布式消息队列、分布式锁、分布式协调等功能。Zookeeper从设计模式角度来理解:是一个基于观察者模式设计的分布式服务管理框架,它负责存储和管理大家都关心的数据,然后接受观察者的注册,一旦这些数据的状态发生变化,Zookeeper 就将负责通知已经在Zookeeper上注册的那些观察者做出相应的反应。
Zookeeper介绍
1)修改配置文件解压安装包后进入conf目录,复制zoo_sample.cfg,修改为zoo.cfg cp zoo_sample.cfg zoo.cfg修改 zoo.cfg 配置文件,将 dataDir=/tmp/zookeeper 修改为指定的data目录
2)启动zookeeper server# 可以通过 bin/zkServer.sh 来查看都支持哪些参数 # 默认加载配置路径conf/zoo.cfgbin/zkServer.sh start conf/zoo.cfg# 查看zookeeper状态bin/zkServer.sh status
3)启动zookeeper client连接Zookeeper serverbin/zkCli.sh# 连接远程的zookeeper serverbin/zkCli.sh -server ip:port
常见cli命令https://zookeeper.apache.org/doc/r3.8.0/zookeeperCLI.html命令基本语法 功能描述help 显示所有操作命令ls [-s] [-w] [-R] path 使用 ls 命令来查看当前 znode 的子节点 [可监听] -w: 监听子节点变化 -s: 节点状态信息(时间戳、版本号、数据大小等)-R: 表示递归的获取create [-s] [-e] [-c] [-t ttl] path [data] [acl]创建节点-s : 创建有序节点。-e : 创建临时节点。-c : 创建一个容器节点。t ttl] : 创建一个TTL节点, -t 时间(单位毫秒)。data:节点的数据,可选,如果不使用时,节点数据就为null。acl:访问控制get [-s] [-w] path获取节点数据信息 -s: 节点状态信息(时间戳、版本号、数据大小等) -w: 监听节点变化set [-s] [-v version] path data设置节点数据-s:表示节点为顺序节点-v: 指定版本号getAcl [-s] path获取节点的访问控制信息-s: 节点状态信息(时间戳、版本号、数据大小等)setAcl [-s] [-v version] [-R] path acl设置节点的访问控制列表-s:节点状态信息(时间戳、版本号、数据大小等)-v:指定版本号-R:递归的设置stat [-w] path查看节点状态信息 delete [-v version] path删除某一节点,只能删除无子节点的节点。-v: 表示节点版本号deleteall path递归的删除某一节点及其子节点setquota -n|-b val path对节点增加限制n:表示子节点的最大个数b:数据值的最大长度,-1表示无限制
客户端命令行操作
节点分类一个znode可以使持久性的,也可以是临时性的:1. 持久节点(PERSISTENT): 这样的znode在创建之后即使发生ZooKeeper集群宕机或者client宕机也不会丢失。2. 临时节点(EPHEMERAL ): client宕机或者client在指定的timeout时间内没有给ZooKeeper集群发消息,这样的znode就会消失。如果上面两种znode具备顺序性,又有以下两种znode :3. 持久顺序节点(PERSISTENT_SEQUENTIAL): znode除了具备持久性znode的特点之外,znode的名字具备顺序性。4. 临时顺序节点(EPHEMERAL_SEQUENTIAL): znode除了具备临时性znode的特点之外,zorde的名字具备顺序性。zookeeper主要用到的是以上4种节点。
5. Container节点 (3.5.3版本新增):Container容器节点,当容器中没有任何子节点,该容器节点会被zk定期删除(定时任务默认60s 检查一次)。 和持久节点的区别是 ZK 服务端启动后,会有一个单独的线程去扫描,所有的容器节点,当发现容器节点的子节点数量为 0 时,会自动删除该节点。可以用于 leader 或者锁的场景中。
6. TTL节点: 带过期时间节点,默认禁用,需要在zoo.cfg中添加 extendedTypesEnabled=true 开启。 注意:ttl不能用于临时节点#创建持久节点create /servers xxx#创建临时节点create -e /servers/host xxx#创建临时有序节点create -e -s /servers/host xxx#创建容器节点create -c /container xxx# 创建ttl节点create -t 10 /ttl
ZooKeeper的数据模型是层次模型,层次模型常见于文件系统。层次模型和key-value模型是两种主流的数据模型。ZooKeeper使用文件系统模型主要基于以下两点考虑:文件系统的树形结构便于表达数据之间的层次关系文件系统的树形结构便于为不同的应用分配独立的命名空间( namespace ) ZooKeeper的层次模型称作Data Tree,Data Tree的每个节点叫作Znode。不同于文件系统,每个节点都可以保存数据,每一个 ZNode 默认能够存储 1MB 的数据,每个 ZNode 都可以通过其路径唯一标识,每个节点都有一个版本(version),版本从0开始计数。
节点状态信息
一个Watch事件是一个一次性的触发器,当被设置了Watch的数据发生了改变的时候,则服务器将这个改变发送给设置了Watch的客户端,以便通知它们。Zookeeper采用了 Watcher机制实现数据的发布订阅功能,多个订阅者可同时监听某一特定主题对象,当该主题对象的自身状态发生变化时例如节点内容改变、节点下的子节点列表改变等,会实时、主动通知所有订阅者。watcher机制事件上与观察者模式类似,也可看作是一种观察者模式在分布式场景下的实现方式。watcher的过程: 客户端向服务端注册watcher 服务端事件发生触发watcher 客户端回调watcher得到触发事件情况注意:Zookeeper中的watch机制,必须客户端先去服务端注册监听,这样事件发送才会触发监听,通知给客户端。支持的事件类型:None: 连接建立事件NodeCreated: 节点创建NodeDeleted: 节点删除NodeDataChanged:节点数据变化NodeChildrenChanged:子节点列表变化DataWatchRemoved:节点监听被移除ChildWatchRemoved:子节点监听被移除
特性 说明一次性触发 watcher是一次性的,一旦被触发就会移除,再次使用时需要重新注册客户端顺序回调 watcher回调是顺序串行执行的,只有回调后客户端才能看到最新的数据状态。一个watcher回调逻辑不应该太多,以免影响别的watcher执行轻量级 WatchEvent是最小的通信单位,结构上只包含通知状态、事件类型和节点路径,并不会告诉数据节点变化前后的具体内容时效性 watcher只有在当前session彻底失效时才会无效,若在session有效期内快速重连成功,则watcher依然存在,仍可接收到通知;
#监听节点数据的变化get -w path stat -w path#监听子节点增减的变化 ls -w path
#master1create -e /master \"m1:2223\" #master2create -e /master \"m2:2223\" # /master已经存在,创建失败Node already exists: /master#监听/master节点stat -w /master#当master2收到/master节点删除通知后可以再次发起创建节点操作create -e /master \"m2:2223\"
master-slave选举也可以用这种方式
master监控worker状态的设计思路#master服务create /workers#让master服务监控/workers下的子节点ls -w /workers#worker1create -e /workers/w1 \"w1:2224\" #创建子节点,master服务会收到子节点变化通知#master服务ls -w /workers#worker2create -e /workers/w2 \"w2:2224\" #创建子节点,master服务会收到子节点变化通知#master服务ls -w /workers#worker2quit #worker2退出,master服务会收到子节点变化通知
使用案例——协同服务
设想用2 /c实现一个counter,使用set命令来实现自增1操作。条件更新场景∶1. 客户端1把/c更新到版本1,实现/c的自增1。2. 客户端2把/c更新到版本2,实现/c的自增1。3. 客户端1不知道/c已经被客户端⒉更新过了,还用过时的版本1是去更新/c,更新失败。如果客户端1使用的是无条件更新,/c就会更新为2,没有实现自增1。使用条件更新可以避免出现客户端基于过期的数据进行数据更新的操作。
使用场景——条件更新
监听通知(watcher)机制
ZooKeeper数据结构 ZooKeeper 数据模型的结构与 Unix 文件系统很类似,整体上可以看作是一棵树,每个节点称做一个 ZNode。
Zookeeper实战
1. 同一级节点 key 名称是唯一的已存在/lock节点,再次创建会提示已经存在
2.创建节点时,必须要带上全路径3.session 关闭,临时节点清除
4.自动创建顺序节点
5.watch 机制,监听节点变化事件监听机制类似于观察者模式,watch 流程是客户端向服务端某个节点路径上注册一个 watcher,同时客户端也会存储特定的 watcher,当节点数据或子节点发生变化时,服务端通知客户端,客户端进行回调处理。特别注意:监听事件被单次触发后,事件就失效了。6.delete 命令只能一层一层删除。提示:新版本可以通过 deleteall 命令递归删除。
Zookeeper 节点特性总结
ZooKeeper适用于存储和协同相关的关键数据,不适合用于大数据量存储。有了上述众多节点特性,使得 zookeeper 能开发不出不同的经典应用场景,比如:注册中心数据发布/订阅(常用于实现配置中心)负载均衡命名服务分布式协调/通知集群管理Master选举分布式锁分布式队列
统一命名服务在分布式环境下,经常需要对应用/服务进行统一命名,便于识别。例如:IP不容易记住,而域名容易记住。
利用 ZooKeeper 顺序节点的特性,制作分布式的序列号生成器,或者叫 id 生成器。(分布式环境下使用作为数据库 id,另外一种是 UUID(缺点:没有规律)),ZooKeeper 可以生成有顺序的容易理解的同时支持分布式环境的编号。/└── /order ├── /order-date1-000000000000001 ├── /order-date2-000000000000002 ├── /order-date3-000000000000003 ├── /order-date4-000000000000004 └── /order-date5-000000000000005
数据发布/订阅数据发布/订阅的一个常见的场景是配置中心,发布者把数据发布到 ZooKeeper 的一个或一系列的节点上,供订阅者进行数据订阅,达到动态获取数据的目的。配置信息一般有几个特点: 数据量小的KV 数据内容在运行时会发生动态变化 集群机器共享,配置一致ZooKeeper 采用的是推拉结合的方式。 推: 服务端会推给注册了监控节点的客户端 Watcher 事件通知 拉: 客户端获得通知后,然后主动到服务端拉取最新的数据
统一集群管理分布式环境中,实时掌握每个节点的状态是必要的,可根据节点实时状态做出一些调整。 ZooKeeper可以实现实时监控节点状态变化:可将节点信息写入ZooKeeper上的一个ZNode。监听这个ZNode可获取它的实时状态变化。
负载均衡在Zookeeper中记录每台服务器的访问数,让访问数最少的服务器去处理最新的客户端请求
应用场景
永久性Watch 在被触发之后,仍然保留,可以继续监听ZNode上的变更,是Zookeeper 3.6.0版本新增的功能addWatch [-m mode] pathaddWatch的作用是针对指定节点添加事件监听,支持两种模式PERSISTENT,持久化订阅,针对当前节点的修改和删除事件,以及当前节点的子节点的删除和新增事件。PERSISTENT_RECURSIVE,持久化递归订阅,在PERSISTENT的基础上,增加了子节点修改的事件触发,以及子节点的子节点的数据变化都会触发相关事件(满足递归订阅特性)
模式 描述world授权对象只有一个anyone,代表登录到服务器的所有客户端都能对该节点执行某种权限ip对连接的客户端使用IP地址认证方式进行认证auth使用以添加认证的用户进行认证digest使用 用户:密码方式验证
授权命令 用法描述getAcl getAcl path读取节点的ACLsetAcl setAcl path acl设置节点的ACLcreate create path data acl创建节点时设置acladdAuth addAuth scheme auth添加认证用户,类似于登录操作
ACL 构成zookeeper 的 acl 通过 [scheme:id:permissions] 来构成权限列表。scheme:授权的模式,代表采用的某种权限机制,包括 world、auth、digest、ip、super 几种。id:授权对象,代表允许访问的用户。如果我们选择采用 IP 方式,使用的授权对象可以是一个 IP 地址或 IP 地址段;而如果使用 Digest 或 Super 方式,则对应于一个用户名。如果是 World 模式,是授权系统中所有的用户。permissions:授权的权限,权限组合字符串,由 cdrwa 组成,其中每个字母代表支持不同权限, 创建权限 create(c)、删除权限 delete(d)、读权限 read(r)、写权限 write(w)、管理权限admin(a)。
ACL权限控制Zookeeper 权限控制 ACL
特性和数据类型
集群角色Leader: 领导者。事务请求(写操作)的唯一调度者和处理者,保证集群事务处理的顺序性;集群内部各个服务器的调度者。对于create、setData、delete等有写操作的请求,则要统一转发给leader处理,leader需要决定编号、执行操作,这个过程称为事务。Follower: 跟随者处理客户端非事务(读操作)请求(可以直接响应),转发事务请求给Leader;参与集群Leader选举投票。Observer: 观察者对于非事务请求可以独立处理(读操作),对于事务性请求会转发给leader处理。Observer节点接收来自leader的inform信息,更新自己的本地存储,不参与提交和选举投票。通常在不影响集群事务处理能力的前提下提升集群的非事务处理能力。Observer应用场景:提升集群的读性能。因为Observer和不参与提交和选举的投票过程,所以可以通过往集群里面添加observer节点来提高整个集群的读性能。跨数据中心部署。 比如需要部署一个北京和香港两地都可以使用的zookeeper集群服务,并且要求北京和香港客户的读请求延迟都很低。解决方案就是把香港的节点都设置为observer。
leader节点可以处理读写请求,follower只可以处理读请求。follower在接到写请求时会把写请求转发给leader来处理。Zookeeper数据一致性保证:全局可线性化(Linearizable )写入∶先到达leader的写请求会被先处理,leader决定写请求的执行顺序。客户端FIFO顺序∶来自给定客户端的请求按照发送顺序执行。
集群架构
环境准备:三台虚拟机192.168.65.156192.168.65.190192.168.65.200条件有限也可以在一台虚拟机上搭建zookeeper伪集群
1) 修改zoo.cfg配置,添加server节点配置# 修改数据存储目录dataDir=/data/zookeeper#三台虚拟机 zoo.cfg 文件末尾添加配置server.1=192.168.65.156:2888:3888server.2=192.168.65.190:2888:3888server.3=192.168.65.200:2888:3888server.A=B:C:DA 是一个数字,表示这个是第几号服务器; 集群模式下配置一个文件 myid,这个文件在 dataDir 目录下,这个文件里面有一个数据 就是 A 的值,Zookeeper 启动时读取此文件,拿到里面的数据与 zoo.cfg 里面的配置信息比较从而判断到底是哪个server。 B 是这个服务器的地址; C 是这个服务器Follower与集群中的Leader服务器交换信息的端口; D 是万一集群中的Leader服务器挂了,需要一个端口来重新进行选举,选出一个新的Leader,而这个端口就是用来执行选举时服务器相互通信的端口。
2)创建 myid 文件,配置服务器编号在dataDir对应目录下创建 myid 文件,内容为对应ip的zookeeper服务器编号cd /data/zookeeper# 在文件中添加与 server 对应的编号(注意:上下不要有空行,左右不要有空格)vim myid注意:添加 myid 文件,一定要在 Linux 里面创建,在 notepad++里面很可能乱码
3)启动zookeeper server集群启动前需要关闭防火墙(生产环境需要打开对应端口)# 分别启动三个节点的zookeeper serverbin/zkServer.sh start# 查看集群状态bin/zkServer.sh status
三节点Zookeeper集群搭建
zookeeper 的 leader 选举存在两个阶段,一个是服务器启动时 leader 选举,另一个是运行过程中 leader 服务器宕机。在分析选举原理前,先介绍几个重要的参数:服务器 ID(myid):编号越大在选举算法中权重越大事务 ID(zxid):值越大说明数据越新,权重越大逻辑时钟(epoch-logicalclock):同一轮投票过程中的逻辑时钟值是相同的,每投完一次值会增加选举状态:LOOKING: 竞选状态FOLLOWING: 随从状态,同步 leader 状态,参与投票OBSERVING: 观察状态,同步 leader 状态,不参与投票LEADING: 领导者状态服务器启动时的 leader 选举
运行过程中的 leader 选举当集群中 leader 服务器出现宕机或者不可用情况时,整个集群无法对外提供服务,进入新一轮的 leader 选举。(1)变更状态。leader 挂后,其他非 Oberver服务器将自身服务器状态变更为 LOOKING。(2)每个 server 发出一个投票。在运行期间,每个服务器上 zxid 可能不同。(3)处理投票。规则同启动过程。(4)统计投票。与启动过程相同。(5)改变服务器状态。与启动过程相同。
Zookeeper Leader 选举原理
消息广播Zookeeper 使用单一的主进程 Leader 来接收和处理客户端所有事务请求,并采用 ZAB 协议的原子广播协议,将事务请求以 Proposal 提议广播到所有 Follower 节点,当集群中有过半的Follower 服务器进行正确的 ACK 反馈,那么Leader就会再次向所有的 Follower 服务器发送commit 消息,将此次提案进行提交。这个过程可以简称为 2pc 事务提交,整个流程可以参考下图,注意 Observer 节点只负责同步 Leader 数据,不参与 2PC 数据同步过程。
崩溃恢复在正常情况消息下广播能运行良好,但是一旦 Leader 服务器出现崩溃,或者由于网络原理导致 Leader 服务器失去了与过半 Follower 的通信,那么就会进入崩溃恢复模式,需要选举出一个新的 Leader 服务器。在这个过程中可能会出现两种数据不一致性的隐患,需要 ZAB 协议的特性进行避免。Leader 服务器将消息 commit 发出后,立即崩溃Leader 服务器刚提出 proposal 后,立即崩溃ZAB 协议的恢复模式使用了以下策略:选举 zxid 最大的节点作为新的 leader新 leader 将事务日志中尚未提交的消息进行处理
在 Zookeeper 中,主要依赖 ZAB 协议来实现分布式数据一致性。ZAB 协议分为两部分:消息广播崩溃恢复
Zookeeper 数据同步流程
Zookeeper集群
目前分布式锁,比较成熟、主流的方案:(1)基于数据库的分布式锁。db操作性能较差,并且有锁表的风险,一般不考虑。(2)基于Redis的分布式锁。适用于并发量很大、性能要求很高而可靠性问题可以通过其他方案去弥补的场景。(3)基于ZooKeeper的分布式锁。适用于高可靠(高可用),而并发量不是太高的场景。
基于Zookeeper设计思路一使用临时 znode 来表示获取锁的请求,创建 znode成功的用户拿到锁。思考:上述设计存在什么问题?如果所有的锁请求者都 watch 锁持有者,当代表锁持有者的 znode 被删除以后,所有的锁请求者都会通知到,但是只有一个锁请求者能拿到锁。这就是羊群效应。
基于Zookeeper设计思路二使用临时顺序 znode 来表示获取锁的请求,创建最小后缀数字 znode 的用户成功拿到锁。公平锁的实现在实际的开发中,如果需要使用到分布式锁,不建议去自己“重复造轮子”,而建议直接使用Curator客户端中的各种官方实现的分布式锁,例如其中的InterProcessMutex可重入锁。
Curator 可重入分布式锁工作流程https://www.processon.com/view/link/5cadacd1e4b0375afbef4320总结优点:ZooKeeper分布式锁(如InterProcessMutex),具备高可用、可重入、阻塞锁特性,可解决失效死锁问题,使用起来也较为简单。缺点:因为需要频繁的创建和删除节点,性能上不如Redis。在高性能、高并发的应用场景下,不建议使用ZooKeeper的分布式锁。而由于ZooKeeper的高可用性,因此在并发量不是太高的应用场景中,还是推荐使用ZooKeeper的分布式锁。
Zookeeper 分布式锁实战
第一步:在父pom文件中指定Spring Cloud版本<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.3.2.RELEASE</version> <relativePath/> <!-- lookup parent from repository --></parent><properties> <java.version>1.8</java.version> <spring-cloud.version>Hoxton.SR8</spring-cloud.version></properties><dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>${spring-cloud.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies></dependencyManagement>注意: springboot和springcloud的版本兼容问题
第二步:微服务pom文件中引入Spring Cloud Zookeeper注册中心依赖<!-- zookeeper服务注册与发现 --><dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-zookeeper-discovery</artifactId> <exclusions> <exclusion> <groupId>org.apache.zookeeper</groupId> <artifactId>zookeeper</artifactId> </exclusion> </exclusions></dependency><!-- zookeeper client --><dependency> <groupId>org.apache.zookeeper</groupId> <artifactId>zookeeper</artifactId> <version>3.8.0</version></dependency>注意: zookeeper客户端依赖和zookeeper sever的版本兼容问题Spring Cloud整合Zookeeper注册中心核心源码入口: ZookeeperDiscoveryClientConfiguration
第三步: 微服务配置文件application.yml中配置zookeeper注册中心地址spring: cloud: zookeeper: connect-string: localhost:2181 discovery: instance-host: 127.0.0.1注册到zookeeper的服务实例元数据信息如下:
第四步:整合feign进行服务调用@RequestMapping(value = \"/findOrderByUserId/{id}\")public R findOrderByUserId(@PathVariable(\"id\") Integer id) { log.info(\"根据userId:\"+id+\"查询订单信息\"); //feign调用 R result = orderFeignService.findOrderByUserId(id); return result;}
Zookeeper注册中心实战用于服务注册和服务发现 CP 基于 ZooKeeper 本身的特性可以实现注册中心https://spring.io/projects/spring-cloud-zookeeper#learn
使用场景
ZAB 协议全称:Zookeeper Atomic Broadcast(Zookeeper 原子广播协议)。Zookeeper 是一个为分布式应用提供高效且可靠的分布式协调服务。在解决分布式一致性方面,Zookeeper 并没有使用 Paxos ,而是采用了 ZAB 协议,ZAB是Paxos算法的一种简化实现。ZAB 协议定义:ZAB 协议是为分布式协调服务 Zookeeper 专门设计的一种支持 崩溃恢复 和 原子广播 的协议。下面我们会重点讲这两个东西。基于该协议,Zookeeper 实现了一种 主备模式 的系统架构来保持集群中各个副本之间数据一致性。具体如下图所示:上图显示了 Zookeeper 如何处理集群中的数据。所有客户端写入数据都是写入到Leader节点,然后,由 Leader 复制到Follower节点中,从而保证数据一致性。那么复制过程又是如何的呢?复制过程类似两阶段提交(2PC),ZAB 只需要 Follower(含leader自己的ack) 有一半以上返回 Ack 信息就可以执行提交,大大减小了同步阻塞。也提高了可用性。简单介绍完,开始重点介绍 消息广播 和 崩溃恢复。整个 Zookeeper 就是在这两个模式之间切换。 简而言之,当 Leader 服务可以正常使用,就进入消息广播模式,当 Leader 不可用时,则进入崩溃恢复模式。
通过以上步骤,就能够保持集群之间数据的一致性。还有一些细节:Leader 在收到客户端请求之后,会将这个请求封装成一个事务,并给这个事务分配一个全局递增的唯一 ID,称为事务ID(ZXID),ZAB 协议需要保证事务的顺序,因此必须将每一个事务按照 ZXID 进行先后排序然后处理,主要通过消息队列实现。在 Leader 和 Follwer 之间还有一个消息队列,用来解耦他们之间的耦合,解除同步阻塞。zookeeper集群中为保证任何所有进程能够有序的顺序执行,只能是 Leader 服务器接受写请求,即使是 Follower 服务器接受到客户端的写请求,也会转发到 Leader 服务器进行处理,Follower只能处理读请求。ZAB协议规定了如果一个事务在一台机器上被处理(commit)成功,那么应该在所有的机器上都被处理成功,哪怕机器出现故障崩溃。
消息广播ZAB 协议的消息广播过程使用的是一个原子广播协议,类似一个 两阶段提交过程。对于客户端发送的写请求,全部由 Leader 接收,Leader 将请求封装成一个事务 Proposal,将其发送给所有 Follwer ,然后,根据所有 Follwer 的反馈,如果超过半数(含leader自己)成功响应,则执行 commit 操作。
崩溃恢复刚刚我们说消息广播过程中,Leader 崩溃怎么办?还能保证数据一致吗?实际上,当 Leader 崩溃,即进入我们开头所说的崩溃恢复模式(崩溃即:Leader 失去与过半 Follwer 的联系)。下面来详细讲述。假设1:Leader 在复制数据给所有 Follwer 之后,还没来得及收到Follower的ack返回就崩溃,怎么办?假设2:Leader 在收到 ack 并提交了自己,同时发送了部分 commit 出去之后崩溃怎么办?针对这些问题,ZAB 定义了 2 个原则:ZAB 协议确保丢弃那些只在 Leader 提出/复制,但没有提交的事务。ZAB 协议确保那些已经在 Leader 提交的事务最终会被所有服务器提交。所以,ZAB 设计了下面这样一个选举算法:能够确保提交已经被 Leader 提交的事务,同时丢弃已经被跳过的事务。针对这个要求,如果让 Leader 选举算法能够保证新选举出来的 Leader 服务器拥有集群中所有机器 ZXID 最大的事务,那么就能够保证这个新选举出来的 Leader 一定具有所有已经提交的提案。而且这么做有一个好处是:可以省去 Leader 服务器检查事务的提交和丢弃工作的这一步操作。
数据同步当崩溃恢复之后,需要在正式工作之前(接收客户端请求),Leader 服务器首先确认事务是否都已经被过半的 Follwer 提交了,即是否完成了数据同步。目的是为了保持数据一致。当 Follwer 服务器成功同步之后,Leader 会将这些服务器加入到可用服务器列表中。实际上,Leader 服务器处理或丢弃事务都是依赖着 ZXID 的,那么这个 ZXID 如何生成呢?答:在 ZAB 协议的事务编号 ZXID 设计中,ZXID 是一个 64 位的数字,其中低 32 位可以看作是一个简单的递增的计数器,针对客户端的每一个事务请求,Leader 都会产生一个新的事务 Proposal 并对该计数器进行 + 1 操作。而高 32 位则代表了 Leader 服务器上取出本地日志中最大事务 Proposal 的 ZXID,并从该 ZXID 中解析出对应的 epoch 值(leader选举周期),当一轮新的选举结束后,会对这个值加一,并且事务id又从0开始自增。高 32 位代表了每代 Leader 的唯一性,低 32 代表了每代 Leader 中事务的唯一性。同时,也能让 Follwer 通过高 32 位识别不同的 Leader。简化了数据恢复流程。基于这样的策略:当 Follower 连接上 Leader 之后,Leader 服务器会根据自己服务器上最后被提交的 ZXID 和 Follower 上的 ZXID 进行比对,比对结果要么回滚,要么和 Leader 同步。
ZAB协议介绍
zookeeper
总结自此,Dubbo的改造就完成了,总结一下:1. 添加pom依赖2. 配置dubbo应用名、协议、注册中心3. 定义服务接口和实现类4. 使用@DubboService来定义一个Dubbo服务5. 使用@DubboReference来使用一个Dubbo服务 6. 使用@EnableDubbo开启Dubbo
在服务注册领域,市面上有两种模型,一种是应用级注册,一种是接口级注册,在Spring Cloud中, 一个应用是一个微服务,而在Dubbo2.7中,一个接口是一个微服务。所以,Spring Cloud在进行服务注册时,是把应用名以及应用所在服务器的IP地址和应用所绑定的端 口注册到注册中心,相当于key是应用名,value是ip+port,而在Dubbo2.7中,是把接口名以及对应 应用的IP地址和所绑定的端口注册到注册中心,相当于key是接口名,value是ip+port。所以在Dubbo2.7中,一个应用如果提供了10个Dubbo服务,那么注册中心中就会存储10对 keyvalue,而Spring Cloud就只会存一对keyvalue,所以以Spring Cloud为首的应用级注册是更加适 合的。所以Dubbo3.0中将注册模型也改为了应用级注册,提升效率节省资源的同时,通过统一注册模型,也 为各个微服务框架的互通打下了基础。
注册模型的改变
triple协议基于的是HTTP2,rest协议目前基于的是HTTP1,都可以做到跨语言。 triple协议兼容了gPRC(Triple服务可以直接调用gRPC服务,反过来也可以),rest协议不行 triple协议支持流式调用,rest协议不行 rest协议更方便浏览器、客户端直接调用,triple协议不行(原理上支持,当得对triple协议的底层实现比较熟悉才 行,得知道具体的请求头、请求体是怎么生成的) dubbo协议是Dubbo3.0之前的默认协议,triple协议是Dubbo3.0之后的默认协议,优先用Triple协议 dubbo协议不是基于的HTTP,不够通用,triple协议底层基于HTTP所以更通用(比如跨语言、跨异构系统实现起 来比较方便)dubbo协议不支持流式调用
大概对比一下triple、dubbo、rest这三个协议
新一代RPC协议-Triple协议
unary,就是正常的调用方法
// UNARY@Overridepublic String sayHello(String name) { return \"Hello \" + name;}
服务实现类对应的方法:
String result = userService.sayHello(\"zhouyu\");
服务消费者调用方式:
UNARY
userService.sayHelloServerStream(\"zhouyu\
服务消费者调用方式
SERVER_STREAM
// CLIENT_STREAM@Overridepublic StreamObserver<String> sayHelloStream(StreamObserver<String> response) { return new StreamObserver<String>() { @Override public void onNext(String data) { // 接收客户端发送过来的数据,然后返回数据给客户端 response.onNext(\"result:\" + data); } @Override public void onError(Throwable throwable) {} @Override public void onCompleted() { System.out.println(\"completed\"); } };}
StreamObserver<String> streamObserver = userService.sayHelloStream(new StreamObserver<String>() { @Override public void onNext(String data) { System.out.println(\"接收到响应数据:\"+ data); } @Override public void onError(Throwable throwable) {} @Override public void onCompleted() { System.out.println(\"接收到响应数据完毕\"); }}); // 发送数据streamObserver.onNext(\"request zhouyu hello\");streamObserver.onNext(\"request zhouyu world\");streamObserver.onCompleted();
CLIENT_STREAM
和CLIENT_STREAM一样
BI_STREAM
当使用Triple协议进行RPC调用时,支持多种方式来调用服务,只不过在服务接口中要定义不同的方 法,比如:
Triple协议的流式调用
Dubbo3.0新特性介绍
项目改造
https://www.processon.com/view/link/62c441e80791293dccaebded
@DubboComponentScan注解Import了一个DubboComponentScanRegistrarDubboComponentScanRegistrar中会调用DubboSpringInitializer.initialize()方法中会注册一个DubboDeployApplicationListener,而DubboDeployApplicationListener会监听Spring容器启动完成事件ContextRefreshedEvent,一旦接收到这个事件后,就会开始Dubbo的启动流程,就会执行DefaultModuleDeployer的start()进行服务导出与服务引入。
启动类上加上@EnableDubbo时,该注解上有一个@DubboComponentScan注解
额外先提一下,在启动过程中,在做完服务导出与服务引入后,还会做几件非常重要的事情:1.导出一个应用元数据服务(就是一个MetadataService服务,这个服务也会注册到注册中心,后面会分析它有什么用),或者将应用元数据注册到元数据中心2.生成当前应用的实例信息对象ServiceInstance,比如应用名、实例ip、实例port,并将实例信息注册到注册中心,也就是应用级注册这两个步骤的作用是什么,后面会细讲。
项目启动
1.服务的类型,也就是接口,接口名就是服务名2.服务的具体实现类,也就是当前类3.服务的version、timeout等信息,就是@DubboService中所定义的各种配置
1. 实现类上加上@DubboService后,就表示定义了一个Dubbo服务,应用启动时Dubbo只要扫描到了@DubboService,就会解析对应的类,得到服务相关的配置信息
一个Dubbo服务,除开服务的名字,也就是接口名,还会有很多其他的属性,比如超时时间、版本号、服务所属应用名、所支持的协议及绑定的端口等众多信息。 但是,通常这些信息并不会全部在@DubboService中进行定义,比如,一个Dubbo服务肯定是属于某个应用的,而一个应用下可以有多个Dubbo服务,所以我们可以在应用级别定义一些通用的配置,比如协议。
确定服务参数
tri://192.168.65.221:20880/org.apache.dubbo.springboot.demo.DemoService?application=dubbo-springboot-demo-provider&timeout=3000这个URL就表示了一个Dubbo服务,服务消费者只要能获得到这个服务URL,就知道了关于这个Dubbo服务的全部信息,包括服务名、支持的协议、ip、port、各种配置。
确定了服务URL之后,服务注册要做的事情就是把这个服务URL存到注册中心(比如Zookeeper)中去,说的再简单一点,就是把这个字符串存到Zookeeper中去,这个步骤其实是非常简单的,实现这个功能的源码在RegistryProtocol中的export()方法中,最终服务URL存在了Zookeeper的/dubbo/接口名/providers目录下。而对于服务提供者而言,在服务注册过程中,还需要能监听到动态配置的变化,一旦发生了变化,就根据最新的配置重新生成服务URL,并重新注册到中心。
接口名1:tri://192.168.65.221:20880/接口名1?application=应用名接口名2:tri://192.168.65.221:20880/接口名2?application=应用名接口名3:tri://192.168.65.221:20880/接口名3?application=应用名
在Dubbo3.0之前,Dubbo是接口级注册,服务注册就是把接口名以及服务配置信息注册到注册中心中,注册中心存储的数据格式大概为:
接口名1:tri://192.168.65.221:20880/接口名1?application=应用名接口名2:tri://192.168.65.221:20880/接口名2?application=应用名接口名3:tri://192.168.65.221:20880/接口名3?application=应用名 接口名1:tri://192.168.65.222:20880/接口名1?application=应用名接口名2:tri://192.168.65.222:20880/接口名2?application=应用名接口名3:tri://192.168.65.222:20880/接口名3?application=应用名
key是接口名,value就是服务URL,上面的内容就表示现在有一个应用,该应用下有3个接口,应用实例部署在192.168.65.221,此时,如果给该应用增加一个实例,实例ip为192.168.65.222,那么新的实例也需要进行服务注册,会向注册中心新增3条数据:
可以发现,如果一个应用中有3个Dubbo服务,那么每增加一个实例,就会向注册中心增加3条记录,那如果一个应用中有10个Dubbo服务,那么每增加一个实例,就会向注册中心增加10条记录,注册中心的压力会随着应用实例的增加而剧烈增加。
应用名:192.168.65.221:20880应用名:192.168.65.222:20880
所以为了降低注册中心的压力,Dubbo3.0支持了应用级注册,同时也兼容接口级注册,用户可以逐步迁移成应用级注册,而一旦采用应用级注册,最终注册中心的数据存储就变成为:注册中心存储的数据变少了,注册中心中数据的变化频率变小了(那服务的配置如果发生了改变怎么办呢?后面会讲),并且使用应用级注册,使得 Dubbo3 能实现与异构微服务体系如Spring Cloud、Kubernetes Service等在地址发现层面更容易互通, 为连通 Dubbo与其他微服务体系提供可行方案。
对于这个问题,在进行服务导出的过程中,会在Zookeeper中存一个映射关系,在服务导出的最后一步,在ServiceConfig的exported()方法中,会保存这个映射关系:接口名:应用名这个映射关系存在Zookeeper的/dubbo/mapping目录下,存了这个信息后,消费者就能根据接口名找到所对应的应用名了。
服务消费者怎么知道现在它要用的某个Dubbo服务,也就是某个接口对应的应用是哪个呢?
1.首先,不可能每导出一个服务就进行一次应用注册,太浪费了,应用注册只要做一次就行了2.另外,如果一个应用支持了多个端口,那么应用注册时只要挑选其中一个端口作为实例端口就可以了(该端口只要能接收到数据就行)3.前面提到,应用启动过程中要暴露应用元数据服务,所以在此处也还是要收集当前所暴露的服务配置信息,以提供给应用元数据服务所以ServiceDiscoveryRegistry在注册一个服务URL时,并不会往注册中心存数据,而只是把服务URL存到到一个MetadataInfo对象中,MetadataInfo对象中就保存了当前应用中所有的Dubbo服务信息(服务名、支持的协议、绑定的端口、timeout等)
这两个URL只有schema不一样,一个是service-discovery-registry,一个是registry,而registry是Dubbo3之前就存在的,也就代表接口级服务注册,而service-discovery-registry就表示应用级服务注册。 在服务注册相关的源码中,当调用RegistryProtocol的export()方法处理registry://时,会利用ZookeeperRegistry把服务URL注册到Zookeeper中去,这个我们能理解,这就是接口级注册。 而类似,当调用RegistryProtocol的export()方法处理service-discovery-registry://时,会利用ServiceDiscoveryRegistry来进行相关逻辑的处理,那是不是就是在这里把应用信息注册到注册中心去呢?并没有这么简单。
不管是什么注册,都需要存数据到注册中心,而Dubbo3的源码实现中会根据所配置的注册中心生成两个URL(不是服务URL,可以理解为注册中心URL,用来访问注册中心的):1.service-discovery-registry://127.0.0.1:2181/org.apache.dubbo.registry.RegistryService?application=dubbo-springboot-demo-provider&dubbo=2.0.2&pid=13072&qos.enable=false®istry=zookeeper×tamp=16517555016602.registry://127.0.0.1:2181/org.apache.dubbo.registry.RegistryService?application=dubbo-springboot-demo-provider&dubbo=2.0.2&pid=13072&qos.enable=false®istry=zookeeper×tamp=1651755501660
首先,我们可以通过配置dubbo.application.register-mode来控制:1.instance:表示只进行应用级注册2.interface:表示只进行接口级注册3.all:表示应用级注册和接口级注册都进行,默认
前面提到过,在应用启动的最后,才会进行应用级注册,而应用级注册就是当前的应用实例上相关的信息存入注册中心,包括:1.应用的名字2.获取应用元数据的方式3.当前实例的ip和port4.当前实例支持哪些协议以及对应的port
应用级注册
至此,应用级服务注册的原理就分析完了,总结一下:1.在导出某个Dubbo服务URL时,会把服务URL存入MetadataInfo中2.导出完某个Dubbo服务后,就会把服务接口名:应用名存入元数据中心(可以用Zookeeper实现)3.导出所有服务后,完成服务引入后4.判断要不要启动元数据服务,如果要就进行导出,固定使用Dubbo协议5.将MetadataInfo存入元数据中心6.确定当前实例信息(应用名、ip、port、endpoint)7.将实例信息存入注册中心,完成应用注册
服务注册
服务暴露就是根据不同的协议启动不同的Server,比如dubbo和tri协议启动的都是Netty,像Dubbo2.7中的http协议启动的就是Tomcat,这块在服务调用的时候再来分析(dubbo协议在上期讲了,下节课主要讲triple协议)
服务暴露
而所谓的服务导出,主要就是完成三件事情:1.确定服务的最终参数配置2.按不同协议启动对应的Server(服务暴露)3.将服务注册到注册中心(服务注册)
2. 解析完服务的配置信息后,就会把这些配置信息封装成为一个ServiceConfig对象,并调用其export()进行服务导出,此时一个ServiceConfig对象就表示一个Dubbo服务。
服务导出
利用@DubboReference注解来引入某一个Dubbo服务,应用在启动过程中,进行完服务导出之后,就会进行服务引入,属性的类型就是一个Dubbo服务接口,而服务引入最终要做到的就是给这个属性赋值一个接口代理对象。
在Dubbo2.7中,只有接口级服务注册,服务消费者会利用接口名从注册中心找到该服务接口所有的服务URL,服务消费者会根据每个服务URL的protocol、ip、port生成对应的Invoker对象,比如生成TripleInvoker、DubboInvoker等,调用这些Invoker的invoke()方法就会发送数据到对应的ip、port,生成好所有的Invoker对象之后,就会把这些Invoker对象进行封装并生成一个服务接口的代理对象,代理对象调用某个方法时,会把所调用的方法信息生成一个Invocation对象,并最终通过某一个Invoker的invoke()方法把Invocation对象发送出去,所以代理对象中的Invoker对象是关键,服务引入最核心的就是要生成这些Invoker对象。
Invoker是非常核心的一个概念,也有非常多种类,比如:1.TripleInvoker:表示利用tri协议把Invocation对象发送出去2.DubboInvoker:表示利用dubbo协议把Invocation对象发送出去3.ClusterInvoker:有负载均衡功能4.MigrationInvoker:迁移功能,后面分析,Dubbo3.0新增的
像TripleInvoker和DubboInvoker对应的就是具体服务提供者,包含了服务提供者的ip地址和端口,并且会负责跟对应的ip和port建立Socket连接,后续就可以基于这个Socket连接并按协议格式发送Invocation对象。
讲解
在讲服务导出时,Dubbo3.0默认情况下即会进行接口级注册,也会进行应用级注册,目的就是为了兼容服务消费者应用用的还是Dubbo2.7,用Dubbo2.7就只能老老实实的进行接口级服务引入。
接口级服务引入核心就是要找到当前所引入的服务有哪些服务URL,然后根据每个服务URL生成对应的Invoker,流程为:1.首先,根据当前引入的服务接口生成一个RegistryDirectory对象,表示动态服务目录,用来查询并缓存服务提供者信息。2.RegistryDirectory对象会根据服务接口名去注册中心,比如Zookeeper中的/dubbo/服务接口名/providers/节点下查找所有的服务URL3.根据每个服务URL生成对应的Invoker对象,并把Invoker对象存在RegistryDirectory对象的invokers属性中4.RegistryDirectory对象也会监听/dubbo/服务接口名/providers/节点的数据变化,一旦发生了变化就要进行相应的改变5.最后将RegistryDirectory对象生成一个ClusterInvoker对象,到时候调用ClusterInvoker对象的invoke()方法就会进行负载均衡选出某一个Invoker进行调用
接口级服务引入
在Dubbo中,应用级服务引入,并不是指引入某个应用,这里和SpringCloud是有区别的,在SpringCloud中,服务消费者只要从注册中心找到要调用的应用的所有实例地址就可以了,但是在Dubbo中找到应用的实例地址还远远不够,因为在Dubbo中,我们是直接使用的接口,所以在Dubbo中就算是应用级服务引入,最终还是得找到服务接口有哪些服务提供者。
在进行应用级服务引入时:1.首先,根据当前引入的服务接口生成一个ServiceDiscoveryRegistryDirectory对象,表示动态服务目录,用来查询并缓存服务提供者信息。2.根据接口名去获取/dubbo/mapping/服务接口名节点的内容,拿到的就是该接口所对应的应用名3.有了应用名之后,再去获取/services/应用名节点下的实例信息4.依次遍历每个实例,每个实例都有一个编号revisiona.根据metadata-type进行判断i.如果是local:则调用实例上的元数据服务获取应用元数据(MetadataInfo)ii.如果是remote:则根据应用名从元数据中心获取应用元数据(MetadataInfo)a.获取到应用元数据之后就进行缓存,key为revision,MetadataInfo对象为valueb.这里为什么要去每个实例上获取应用的元数据信息呢?因为有可能不一样,虽然是同一个应用,但是在运行不同的实例的时候,可以指定不同的参数,比如不同的协议,不同的端口,虽然在生产上基本不会这么做,但是Dubbo还是支持了这种情况1.根据从所有实例上获取到的MetadataInfo以及endpoint信息,就能知道所有实例上所有的服务URL(注意:一个接口+一个协议+一个实例 : 对应一个服务URL)2.拿到了这些服务URL之后,就根据当前引入服务的信息进行过滤,会根据引入服务的接口名+协议名,消费者可以在@DubboReference中指定协议,表示只使用这个协议调用当前服务,如果没有指定协议,那么就会去获取tri、dubbo、rest这三个协议对应的服务URL(Dubbo3.0默认只支持这三个协议)3.这样,经过过滤之后,就得到了当前所引入的服务对应的服务URL了4.根据每个服务URL生成对应的Invoker对象,并把Invoker对象存在ServiceDiscoveryRegistryDirectory对象的invokers属性中5.最后将ServiceDiscoveryRegistryDirectory对象生成一个ClusterInvoker对象,到时候调用ClusterInvoker对象的invoke()方法就会进行负载均衡选出某一个Invoker进行调用
应用级服务引入
# dubbo.application.service-discovery.migration 仅支持通过 -D 以及 全局配置中心 两种方式进行配置。dubbo.application.service-discovery.migration=APPLICATION_FIRST # 可选值 # FORCE_INTERFACE,强制使用接口级服务引入# FORCE_APPLICATION,强制使用应用级服务引入# APPLICATION_FIRST,智能选择是接口级还是应用级,默认就是这个
对于前两种强制的方式,没什么特殊,就是上面走上面分析的两个过程,没有额外的逻辑,那对于APPLICATION_FIRST就需要有额外的逻辑了,也就是Dubbo要判断,当前所引入的这个服务,应该走接口级还是应用级,这该如何判断呢? 事实上,在进行某个服务的服务引入时,会统一利用InterfaceCompatibleRegistryProtocol的refer来生成一个MigrationInvoker对象,在MigrationInvoker中有三个属性:private volatile ClusterInvoker<T> invoker; // 用来记录接口级ClusterInvokerprivate volatile ClusterInvoker<T> serviceDiscoveryInvoker; // 用来记录应用级的ClusterInvokerprivate volatile ClusterInvoker<T> currentAvailableInvoker; // 用来记录当前使用的ClusterInvoker,要么是接口级,要么应用级
switch (step) { case APPLICATION_FIRST: // 先进行接口级服务引入得到对应的ClusterInvoker,并赋值给invoker属性 // 再进行应用级服务引入得到对应的ClusterInvoker,并赋值给serviceDiscoveryInvoker属性 // 再根据两者的数量判断到底用哪个,并且把确定的ClusterInvoker赋值给currentAvailableInvoker属性 migrationInvoker.migrateToApplicationFirstInvoker(newRule); break; case FORCE_APPLICATION: // 只进行应用级服务引入得到对应的ClusterInvoker,并赋值给serviceDiscoveryInvoker和currentAvailableInvoker属性 success = migrationInvoker.migrateToForceApplicationInvoker(newRule); break; case FORCE_INTERFACE: default: // 只进行接口级服务引入得到对应的ClusterInvoker,并赋值给invoker和currentAvailableInvoker属性 success = migrationInvoker.migrateToForceInterfaceInvoker(newRule);}
确定了step和threshold之后,就要真正开始给MigrationInvoker对象中的三个属性赋值了,先根据step调用不同的方法
所以在Dubbo3.0中,可以配置:
具体接口级服务引入和应用级服务引入是如何生成ClusterInvoker,前面已经分析过了,我们这里只需要分析当step为APPLICATION_FIRST时,是如何确定最终要使用的ClusterInvoker的。 得到了接口级ClusterInvoker和应用级ClusterInvoker之后,就会利用DefaultMigrationAddressComparator来进行判断:1.如果应用级ClusterInvoker中没有具体的Invoker,那就表示只能用接口级Invoker2.如果接口级ClusterInvoker中没有具体的Invoker,那就表示只能用应用级Invoker3.如果应用级ClusterInvoker和接口级ClusterInvoker中都有具体的Invoker,则获取对应的Invoker个数4.如果在迁移规则和应用参数中都没有配置threshold,那就读取全局配置中心的dubbo.application.migration.threshold参数,如果也没有配置,则threshold默认为0(不是-1了)5.用应用级Invoker数量 / 接口级Invoker数量,得到的结果如果大于等于threshold,那就用应用级ClusterInvoker,否则用接口级ClusterInvoker
MigrationInvoker的生成
服务引入
服务导出和引入
Dubbo
大厂技术面试考察点:1、基础知识2、技术广度3、技术深度4、项目经验5、团队管理 6、hr--参考技术面试评价,学历,背景,软技能,人品 技术评级
1、自我介绍六年以上大型互联网电商与金融项目研发经验,对大型互联网电商的后端架构和整体业务有 深入的理解,三年以上团队研发管理经验,对分布式,高并发,高可用,微服务架构设计有 深度理解,曾负责过注册用户上亿,日活近500万的电商平台的架构设计与研发。对IT技术 有较浓厚的兴趣,喜欢跟踪与钻研新技术以及底层实现。有深入研究过Zookeeper, Dubbo,Netty,Spring以及Spring Cloud等开源框架的源码。
2、掌握技术技能 1、Java基础扎实、掌握JVM原理、多线程、网络原理、设计模式、常用数据结构和算法、设计模式2、深入理解spring,spring mvc mybatis等开源框架设计原理及底层架构,研究过部分核 心功能源码,具备一定的框架定制开发能力 3、深入理解Redis线程模型,熟练掌握redis的核心数据结构的使用场景,熟悉多级缓存架 构,熟悉各种缓存高并发的使用场景,比如缓存雪崩,缓存穿透,缓存失效,热点缓存重建 等 4、熟悉常见消息中间件的使用,解决过各种消息通信场景的疑难问题,比如消息丢失、消 息重复消费,消息顺序性,大规模消息积压问题 5、对于高性能IO通信模型以及相关开源组件Netty等源码有过深度研究,熟悉Netty线程模 型,熟悉百万级并发服务器架构设计6、深入理解JVM底层原理,熟悉JVM各种垃圾收集器的使用以及核心参数的调优,有过一 定的JVM线上调优经验,对JVM调优有自己独到见解7、深入理解spring boot,spring cloud,dubbo等微服务框架的设计原理及底层架构, 研究过核心源码,熟悉各种微服务架构场景设计,比如服务注册与发现,服务限流、降级、 熔断,服务网关路由设计,服务安全认证架构 8、在项目中解决过各种分布式场景的技术难题,比如分布式锁,分布式事务,分布式 session,分布式任务,海量数据的分库分表。。。
3、项目经验 1)做过类似电商项目2)没做过类似电商项目,银行内部项目,OA,ERP,内部管理系统, 想下自己做过的项目如果压力暴增100倍,现有系统是否能抗住,如果扛不住应该怎么优 化,用哪些技术,试着把我们教大家的实战项目里的技术用上去,然后这些就是可以写到简 历上的点,JVM以及Mysql的优化3) 如果实在用不上去,就直接说之前有跟朋友兼职做过什么系统类似的,这种系统压力一 般不会太大,可以在系统优化上写,比如把gc的次数和时间由多少优化到了多少,把qps之 类的由多少优化到了多少
项目必问的细节点:1、项目大体情况2、项目软硬件技术架构 3、项目大体规模,多少人参与,并发量与数据量多大,你在其中的角色gateway:8核16G,抗每秒2000+请求,32核64G可以抗住每秒上万请求,支 撑1万+请求,5台8核16G,支撑10万+请求,10台32核64Gweb服务:这个得根据业务的复杂度来看,一般就单台几百到几千的并发 缓存redis:单台几万的并发,要么用集群架构可以到几十万并发 数据库:正常8核16G扛个大几百并发问题不大,如果并发提高10倍到四五千,要么分库分表横向扩容,要么增大机器配置,比如32核64G高配物理机,扛个五六千 并发问题不大线上实时QPS等性能指标计算4、项目的分布式,缓存,消息,高可用,调优,性能监控拿秒杀下单核心链路举例,看下各环节做了哪些事情,为了解决什么问题 1)缓存缓存架构缓存穿透,雪崩,失效 2)消息 消息中间件选型 消息服务高可用 消息重复消费 丢消息 消息积压3)分布式 为什么要分布式,微服务注册中心用的什么,服务注册与发现原理是什么 注册中心如果出问题或挂了怎么办,服务之间还能继续通信吗 微服务之间调用负载均衡策略有哪些 超卖问题的分布式锁实现原理,redis与zk实现优劣对比 分布式session怎么做的核心交易链路分布式事务分库分表,全局序列id方案 4) 高可用 核心服务链路的限流熔断降级服务雪崩,资源隔离 5) 调优JVM,Mysql,Redis,分布式,微服务分布式中间件的参数调优等 6)服务监控5、项目中的难点以及优化改进点 分布式锁高并发优化缓存与数据库双写不一致 消息积压处理 消息莫名丢失 降级操作的数据补偿 服务扩容 核心服务全链路保证高可用 复杂业务设计(DDD架构)
面试技能
PV: Page view,即网站被浏览的总次数
UV: Unique Vister 的缩写,独立访客
CR: ConversionRate 的缩写,是指访问某一网站访客中,转化的访客占全 访客的比例(订单转化率=有效订单数/访客数)
SPU: Standard Product Unit (标准化产品单元),SPU 是商品信息聚合的 最小单位,是一组可复用、易检索的标准化信息的集合,该集合描述了一个产品 的特性。
SKU: Stock keeping unit(库存量单位) SKU 即库存进出计量的单位(买家购 买、商家进货、供应商备货、工厂生产都是依据 SKU 进行的),在服装、鞋类 商品中使用最多最普遍。
常见电商术语
由于代码库太难于理解,因此开 发人员在更改时更容易出错,每一次更改都会让代码库变得更复杂、更难懂。就 演变成为了我们常说的“代码屎山”
过度的复杂性
开发速度缓慢
难以扩展和可靠性不佳
公司规模较小,开发团队人数较少、产品上线周期短、产品在快速迭代期,核心功能尚未稳定时;或者用户规模和用户群体较少时。
单体应用的适用场景
单体服务问题
代码维护困难,几百人同时开发一个模块,提交代码频繁出现大量冲突;
模块耦合严重,互相依赖,小功能修改也必须累计到大版本才能上线,上线还需要总监协调各个团队开会确定
横向扩展流程复杂,主要业务和次要业务耦合。 例如下单和支付业务 需要扩容,而注册业务不需要扩容
拆分时机
业务规模:业务模式得到市场的验证,需要进一步加快脚步快速占领市场,这时业务的规模变得越来越大,按产品生命周期来划分(导入期、成长期、成熟期、衰退期)这时一般在成长期阶段。如果是导入期,尽量采用单体架构
团队规模:一般是团队达到百人的时候,主要还是要结合业务复杂度
技术储备:领域驱动设计、注册中心、配置中心、日志系统、持续交付、 监控系统、分布式定时任务、CAP 理论、分布式调用链、API 网关等等。
人才储备:精通微服务落地经验的架构师及相应开发人员。
研发效率:研发效率大幅下降。
何时进行微服务的拆分
单一服务内部功能高内聚低耦合:每个服务只完成自己职责内的任务,对于不是 自己职责的功能交给其它服务来完成
闭包原则(CCP):微服务的闭包原则就是当我们需要改变一个微服务的时候, 所有依赖都在这个微服务的组件内,不需要修改其他微服务
服务自治、接口隔离原则:尽量消除对其他服务的强依赖,这样可以降低沟通成 本,提升服务稳定性。服务通过标准的接口隔离,隐藏内部实现细节。这使得服 务可以独立开发、测试、部署、运行,以服务为单位持续交付。
持续演进原则:在服务拆分的初期,你其实很难确定服务究竟要拆成什么样。应逐步划分,持续演进,避免服务数量的爆炸性增长
拆分的过程尽量避免影响产品的日常功能迭代:也就是说要一边做产品功能迭代,一边完成服务化拆分。比如优先剥离比较独立的边界服务(如短信服务等),从非核心的服务出发减少拆分对现有业务的影响,也给团队一个练习、试错的机会。同时当两个服务存在依赖关系时优先拆分被依赖的服务
服务接口的定义要具备可扩展性:比如微服务的接口因为升级把之前的三个参数改成了四个,上线后导致调用方大量报错,推荐做法服务接口的参数类型最好是封装类,这样如果增加参数就不必变更接口的签名
避免环形依赖与双向依赖:尽量不要有服务之间的环形依赖或双向依赖,原因是存在这种情况说明我们的功能边界没有化分清楚或者有通用的功能没有下沉下来。
阶段性合并:随着你对业务领域理解的逐渐深入或者业务本身逻辑发生了比较大的变化,亦或者之前的拆分没有考虑的很清楚,导致拆分后的服务边界变得越来越混乱,这时就要重新梳理领域边界,不断纠正拆分的合理性
自动化驱动:部署和运维的成本会随着服务的增多呈指数级增长,每个服务都需要部署、监控、日志分析等运维工作,成本会显著提升。因此,在服务划分之前,应该首先构建自动化的工具及环境。开发人员应该以自动化为驱动力,简化服务在创建、开发、测试、部署、运维上的重复性工作,通过工具实现更可靠的操作,避免微服务数量增多带来的开发、管理复杂度问题
微服务拆分的一些通用原则
自下而上的架构设计方法,通过分析需求, 确定整体数据结构,根据表之间的关系拆分服务
拆分步骤: 需求分析,抽象数据结构,划分服务,确定调用关系和业务 流程验证。
基于数据驱动拆分服务
自上而下的架构设计方法,通过和领域专家 建立统一的语言,不断交流,确定关键业务场景,逐步确定边界上下文
拆分步骤:通过模型和领域专家建立统一语言,业务分析,寻找聚合,确定服务调用关系,业务流程验证和持续优化
基于领域驱动拆分服务
拆分步骤: 前后端分离,提取公共基础服务(如授权服务,分布式ID服 务),不断从老系统抽取服务,垂直划分优先,适当水平切分
还有一种常见拆分场景,从已有单体架构中逐步拆分服务
功能维度拆分策略
区分系统中变与不变的部分,不变的部分一般是成熟的、通用的服务功能,变的 部分一般是改动比较多、满足业务迭代扩展性需要的功能,我们可以将不变的部 分拆分出来,作为共用的服务,将变的部分独立出来满足个性化扩展需要 同时根据二八原则,系统中经常变动的部分大约只占 20%,而剩下的 80% 基本 不变或极少变化,这样的拆分也解决了发布频率过多而影响成熟服务稳定性的问 题。 唯一id,redis操作,不变的部分。
扩展性
不同的业务里或服务里经常会出现重复的功能,比如每个服务都有鉴权、限流、 安全及日志监控等功能,可以将这些通过的功能拆分出来形成独立的服务
复用性
将性能要求高或者性能压力大的模块拆分出来,避免性能压力大的服务影响其它 服务。我们也可以基于读写分离来拆分,比如电商的商品信息,在 App 端主要是商品 详情有大量的读取操作,但是写入端商家中心访问量确很少。因此可以对流量较 大或较为核心的服务做读写分离,拆分为两个服务发布,一个负责读,另外一个 负责写。 数据一致性是另一个基于性能维度拆分需要考虑的点,对于强一致的数据,属于 强耦合,尽量放在同一个服务中(但是有时会因为各种原因需要进行拆分,那就 需要有响应的机制进行保证),弱一致性通常可以拆分为不同的服务
高性能
将可靠性要求高的核心服务和可靠性要求低的非核心服务拆分开来,然后重点保 证核心服务的高可用。具体拆分的时候,核心服务可以是一个也可以是多个,只 要最终的服务数量满足“三个火枪手”的原则就可以。 秒杀
高可用
不同的服务可能对信息安全有不同的要求,因此把需要高度安全的服务拆分出 来,进行区别部署,比如设置特定的 DMZ 区域对服务进行分区部署,可以更有 针对性地满足信息安全的要求,也可以降低对防火墙等安全设备吞吐量、并发性 等方面的要求,降低成本,提高效率。
安全性
对于对开发语言种类有要求的业务场景,可以用不同的语言将其功能独立出来实现一个独立服务。
异构性
非功能维度拆分策略
微服务拆分策略
Spring Cloud Alibaba官网:https://github.com/alibaba/spring-cloud-alibaba/wikiSpringCloud的几大痛点:SpringCloud部分组件停止维护和更新,给开发带来不便;SpringCloud部分环境搭建复杂,没有完善的可视化界面,我们需要大量的二次开发和定制SpringCloud配置复杂,难以上手,部分配置差别难以区分和合理应用SpringCloud Alibaba的优势:阿里使用过的组件经历了考验,性能强悍,设计合理,现在开源出来大家用成套的产品搭配完善的可视化界面给开发运维带来极大的便利搭建简单,学习曲线低。所以我们优先选择Spring Cloud Alibaba提供的微服务组件Spring Cloud Alibaba官方推荐版本选择:https://github.com/alibaba/spring-cloud-alibaba/wiki/%E7%89%88%E6%9C%AC%E8%AF%B4%E6%98%8E
微服务版本
方案一:使用logstash日志插件
方案二:标准的ELK收集方案:通过FileBeat收集本地日志
整合ELK收集微服务链路日志
灰度发布 Gray Release(又名金丝雀发布 Canary Release)。不停机旧版本,部署新版本,高比例流量(例如:95%)走旧版本,低比例流量(例如:5%)切换到新版本,通过监控观察无问题,逐步扩大范围,最终把所有流量都迁移到新版本上。属无损发布。
优点灵活简单,不需要用户标记驱动。安全性高,新版本如果出现问题,只会发生在低比例的流量上缺点成本较高,需要部署稳定/灰度两套环境
灰度发布
微服务体系架构中,服务之间的依赖关系错综复杂,有时某个功能发版依赖多个服务同时升级上线。我们希望可以对这些服务的新版本同时进行小流量灰度验证,这就是微服务架构中特有的全链路灰度场景,通过构建从网关到整个后端服务的环境隔离来对多个不同版本的服务进行灰度验证。在发布过程中,我们只需部署服务的灰度版本,流量在调用链路上流转时,由流经的网关、各个中间件以及各个微服务来识别灰度流量,并动态转发至对应服务的灰度版本。
上图可以很好展示这种方案的效果,我们用不同的颜色来表示不同版本的灰度流量,可以看出无论是微服务网关还是微服务本身都需要识别流量,根据治理规则做出动态决策。当服务版本发生变化时,这个调用链路的转发也会实时改变。相比于利用机器搭建的灰度环境,这种方案不仅可以节省大量的机器成本和运维人力,而且可以帮助开发者实时快速的对线上流量进行精细化的全链路控制。
微服务全链路灰度
标签路由标签路由通过对服务下所有节点按照标签名和标签值不同进行分组,使得订阅该服务节点信息的服务消费端可以按需访问该服务的某个分组,即所有节点的一个子集。服务消费端可以使用服务提供者节点上的任何标签信息,根据所选标签的实际含义,消费端可以将标签路由应用到更多的业务场景中。
节点打标那么如何给服务节点添加不同的标签呢?在使用Kubernetes Service作为服务发现的业务系统中,服务提供者通过向ApiServer提交Service资源完成服务暴露,服务消费端监听与该Service资源下关联的Endpoint资源,从Endpoint资源中获取关联的业务Pod 资源,读取上面的Labels数据并作为该节点的元数据信息。所以,我们只要在业务应用描述资源Deployment中的Pod模板中为节点添加标签即可。在使用Nacos作为服务发现的业务系统中,一般是需要业务根据其使用的微服务框架来决定打标方式。如果Java应用使用的Spring Cloud微服务开发框架,我们可以为业务容器添加对应的环境变量来完成标签的添加操作。比如我们希望为节点添加版本灰度标,那么为业务容器添加`spring.cloud.nacos.discovery.metadata.version=gray`,这样框架向Nacos注册该节点时会为其添加一个标签`verison=gray`。
流量染色请求链路上各个组件如何识别出不同的灰度流量?答案就是流量染色,为请求流量添加不同灰度标识来方便区分。我们可以在请求的源头上对流量进行染色,前端在发起请求时根据用户信息或者平台信息的不同对流量进行打标。如果前端无法做到,我们也可以在微服务网关上对匹配特定路由规则的请求动态添加流量标识。此外,流量在链路中流经灰度节点时,如果请求信息中不含有灰度标识,需要自动为其染色,接下来流量就可以在后续的流转过程中优先访问服务的灰度版本。
分布式链路追踪如何保证灰度标识能够在链路中一直传递下去呢?借助于分布式链路追踪思想,我们也可以传递一些自定义信息,比如灰度标识。
总结首先,需要支持动态路由功能,对于Spring Cloud、Dubbo开发框架,可以对出口流量实现自定义Filter,在该Filter中完成流量识别以及标签路由。同时需要借助分布式链路追踪技术完成流量标识链路传递以及流量自动染色。此外,需要引入一个中心化的流量治理平台,方便各个业务线的开发者定义自己的全链路灰度规则。实现全链路灰度的能力,无论是成本还是技术复杂度都是比较高的,以及后期的维护、扩展都是非常大的成本。
那么全链路灰度具体是如何实现呢?我们需要解决以下问题:1.链路上各个组件和服务能够根据请求流量特征进行动态路由。2.需要对服务下的所有节点进行分组,能够区分版本。3.需要对流量进行灰度标识、版本标识。4.需要识别出不同版本的灰度流量。
全链路灰度设计思路
Discovery【探索】企业级云原生微服务开源解决方案官方文档:全链路灰度发布https://github.com/Nepxion/Discovery/wiki
在nacos配置中心中增加网关的版本权重灰度发布策略,Group为discovery-group,Data Id为tulingmall-gateway,策略内容如下,实现从网关发起的调用全链路1.0版本流量权重为90%,1.1版本流量权重为10%<?xml version=\"1.0\" encoding=\"UTF-8\"?><rule> <strategy> <version-weight>1.0=90;1.1=10</version-weight> </strategy></rule>
也可以指定具体每个微服务的版本权重<?xml version=\"1.0\" encoding=\"UTF-8\"?><rule> <strategy> <version-weight>{\"tulingmall-member\":\"1.0=90;1.1=10\
全链路版本权重灰度发布
根据前端传递的Header参数动态选择百分比流量分配<?xml version=\"1.0\" encoding=\"UTF-8\"?><rule> <strategy-release> <conditions type=\"gray\"> <!-- 灰度路由1,条件expression驱动 --> <condition id=\"gray-condition-1\" expression=\"#H['a'] == '1'\" version-id=\"gray-route=10;stable-route=90\"/> <!-- 灰度路由2,条件expression驱动 --> <condition id=\"gray-condition-2\" expression=\"#H['a'] == '1' and #H['b'] == '2'\" version-id=\"gray-route=85;stable-route=15\"/> <!-- 兜底路由,无条件expression驱动 --> <condition id=\"basic-condition\" version-id=\"gray-route=0;stable-route=100\"/> </conditions> <routes> <route id=\"gray-route\" type=\"version\">{\"tulingmall-member\":\"1.1\
全链路版本条件权重灰度发布
基于Discovery实现全链路灰度
3. 微服务全链路灰度解决方案
微服务
网关在认证授权体系里主要负责两件事:(1)作为OAuth2.0的资源服务器角色,实现接入方访问权限拦截。(2)令牌解析并转发当前登录用户信息(明文token)给微服务微服务拿到明文token(明文token中包含登录用户的身份和权限信息)后也需要做两件事:(1)用户授权拦截(看当前用户是否有权访问该资源)(2)将用户信息存储进当前线程上下文(有利于后续业务逻辑随时获取当前用户信息)
授权中心的认证依赖:1.第三方客户端的信息2.微服务的信息3.登录用户的信息
<dependency> <groupId>com.alibaba</groupId> <artifactId>druid-spring-boot-starter</artifactId></dependency><dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId></dependency><dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId></dependency><dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId></dependency><dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId></dependency>
2.1 引入依赖
server: port: 9999spring: application: name: tulingmall-authcenter #配置nacos注册中心地址 cloud: nacos: discovery: server-addr: 192.168.65.103:8848 #注册中心地址 namespace: 6cd8d896-4d19-4e33-9840-26e4bee9a618 #环境隔离 datasource: url: jdbc:mysql://tuling.com:3306/tlmall_oauth?serverTimezone=UTC&useSSL=false&useUnicode=true&characterEncoding=UTF-8 username: root password: root druid: initial-size: 5 #连接池初始化大小 min-idle: 10 #最小空闲连接数 max-active: 20 #最大连接数 web-stat-filter: exclusions: \
2.2 添加yml配置
@Configuration@EnableAuthorizationServerpublic class TulingAuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { @Autowired private DataSource dataSource; @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { // 配置授权服务器存储第三方客户端的信息 基于DB存储 oauth_client_details clients.withClientDetails(clientDetails()); } @Bean public ClientDetailsService clientDetails(){ return new JdbcClientDetailsService(dataSource); } }
基于DB模式配置授权服务器存储第三方客户端的信息
//TulingAuthorizationServerConfig.java@Overridepublic void configure(ClientDetailsServiceConfigurer clients) throws Exception { // 配置授权服务器存储第三方客户端的信息 基于DB存储 oauth_client_details // clients.withClientDetails(clientDetails()); /** *授权码模式 *http://localhost:9999/oauth/authorize?response_type=code&client_id=client&redirect_uri=http://www.baidu.com&scope=all * * password模式 * http://localhost:8080/oauth/token?username=fox&password=123456&grant_type=password&client_id=client&client_secret=123123&scope=all * */ clients.inMemory() //配置client_id .withClient(\"client\") //配置client-secret .secret(passwordEncoder.encode(\"123123\")) //配置访问token的有效期 .accessTokenValiditySeconds(3600) //配置刷新token的有效期 .refreshTokenValiditySeconds(864000) //配置redirect_uri,用于授权成功后跳转 .redirectUris(\"http://www.baidu.com\") //配置申请的权限范围 .scopes(\"all\") /** * 配置grant_type,表示授权类型 * authorization_code: 授权码 * password: 密码 * refresh_token: 更新令牌 */ .authorizedGrantTypes(\"authorization_code\
基于内存模式配置授权服务器存储第三方客户端的信息
2.3 配置授权服务器
@Configurationpublic class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Autowired private TulingUserDetailsService tulingUserDetailsService; @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { // 实现UserDetailsService获取用户信息 auth.userDetailsService(tulingUserDetailsService); } @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { // oauth2 密码模式需要拿到这个bean return super.authenticationManagerBean(); } @Override protected void configure(HttpSecurity http) throws Exception { http.formLogin().permitAll() .and().authorizeRequests() .antMatchers(\"/oauth/**\").permitAll() .anyRequest() .authenticated() .and().logout().permitAll() .and().csrf().disable(); }}
获取会员信息,此处通过feign从tulingmall-member获取会员信息,需要配置feign,核心代码:@Slf4j@Componentpublic class TulingUserDetailsService implements UserDetailsService { @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // 加载用户信息 if(StringUtils.isEmpty(username)) { log.warn(\"用户登陆用户名为空:{}\
2.4 配置SpringSecurity
授权码(authorization code)方式,指的是第三方应用先申请一个授权码,然后再用该码获取令牌。这种方式是最常用的流程,安全性也最高,它适用于那些有后端的 Web 应用。授权码通过前端传送,令牌则是储存在后端,而且所有与资源服务器的通信都在后端完成。这样的前后端分离,可以避免令牌泄漏。
适用场景:目前市面上主流的第三方验证都是采用这种模式
它的步骤如下:(A)用户访问客户端,后者将前者导向授权服务器。(B)用户选择是否给予客户端授权。(C)假设用户给予授权,授权服务器将用户导向客户端事先指定的\"重定向URI\"(redirection URI),同时附上一个授权码。(D)客户端收到授权码,附上早先的\"重定向URI\",向授权服务器申请令牌。这一步是在客户端的后台的服务器上完成的,对用户不可见。(E)授权服务器核对了授权码和重定向URI,确认无误后,向客户端发送访问令牌(access token)和更新令牌(refresh token)。
授权码模式
如果你高度信任某个应用,RFC 6749 也允许用户把用户名和密码,直接告诉该应用。该应用就使用你的密码,申请令牌,这种方式称为\"密码式\"(password)。在这种模式中,用户必须把自己的密码给客户端,但是客户端不得储存密码。这通常用在用户对客户端高度信任的情况下,比如客户端是操作系统的一部分,或者由一个著名公司出品。而授权服务器只有在其他授权模式无法执行的情况下,才能考虑使用这种模式。适用场景:自家公司搭建的授权服务器测试获取token
密码模式
2.5 测试模拟用户登录
@Configuration@EnableResourceServerpublic class TulingResourceServerConfig extends ResourceServerConfigurerAdapter { @Override public void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .anyRequest().authenticated(); }}@RestController@RequestMapping(\"/user\")public class UserController { @RequestMapping(\"/getCurrentUser\") public Object getCurrentUser(Authentication authentication) { return authentication.getPrincipal(); }}
2.6 配置资源服务器
JSON Web Token(JWT)是一个开放的行业标准(RFC 7519),它定义了一种简介的、自包含的协议格式,用于在通信双方传递json对象,传递的信息经过数字签名可以被验证和信任。JWT可以使用HMAC算法或使用RSA的公钥/私钥对来签名,防止被篡改。官网:https://jwt.io/
头部(header)头部用于描述关于该JWT的最基本的信息:类型(即JWT)以及签名所用的算法(如HMACSHA256或RSA)等。这也可以被表示成一个JSON对象:{ \"alg\": \"HS256\
JWT组成一个JWT实际上就是一个字符串,它由三部分组成,头部(header)、载荷(payload)与签名(signature)。
引入依赖<!--spring secuity对jwt的支持 spring cloud oauth2已经依赖,可以不配置--><dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-jwt</artifactId> <version>1.0.9.RELEASE</version></dependency>
添加JWT配置@Configurationpublic class JwtTokenStoreConfig { @Bean public TokenStore jwtTokenStore(){ return new JwtTokenStore(jwtAccessTokenConverter()); } @Bean public JwtAccessTokenConverter jwtAccessTokenConverter(){ JwtAccessTokenConverter accessTokenConverter = new JwtAccessTokenConverter(); //配置JWT使用的秘钥 accessTokenConverter.setSigningKey(\"123123\"); return accessTokenConverter; }}
在授权服务器配置中指定令牌的存储策略为JWT //TulingAuthorizationServerConfig.java@Autowired@Qualifier(\"jwtTokenStore\
2.7 Spring Security Oauth2整合JWT
第二步:授权服务中增加jwt的属性配置类@Data@ConfigurationProperties(prefix = \"tuling.jwt\")public class JwtCAProperties { /** * 证书名称 */ private String keyPairName; /** * 证书别名 */ private String keyPairAlias; /** * 证书私钥 */ private String keyPairSecret; /** * 证书存储密钥 */ private String keyPairStoreSecret;}@Configuration// 指定属性配置类@EnableConfigurationProperties(value = JwtCAProperties.class) public class JwtTokenStoreConfig { 。。。。。。}
yml中添加jwt配置tuling: jwt: keyPairName: jwt.jks keyPairAlias: jwt keyPairSecret: 123123 keyPairStoreSecret: 123123
第三步:修改JwtTokenStoreConfig的配置,支持非对称加密 @Beanpublic JwtAccessTokenConverter jwtAccessTokenConverter(){ JwtAccessTokenConverter accessTokenConverter = new JwtAccessTokenConverter(); //配置JWT使用的秘钥 //accessTokenConverter.setSigningKey(\"123123\
2.8 优化:实现JWT非对称加密(公钥私钥)
搭建微服务授权中心
配置授权服务器存储第三方客户端的信息 基于DB存储 oauth_client_details@Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.withClientDetails(jdbcClientDetailsService()); }
@Override public UserDetails loadUserDetails(T authentication) throws UsernameNotFoundException { String clientId = RequestUtils.getClientId(); // 获取认证身份标识,默认是用户名:username UserDetailsService userDetailsService = userDetailsServiceMap.get(clientId); if (clientId.equals(SecurityConstants.APP_CLIENT_ID)) { // 移动端的用户体系是会员,认证方式是通过手机号 mobile 认证 MemberUserDetailsServiceImpl memberUserDetailsService = (MemberUserDetailsServiceImpl) userDetailsService; return memberUserDetailsService.loadUserByUsername(authentication.getName()); } else if (clientId.equals(SecurityConstants.WEAPP_CLIENT_ID)) { // 小程序的用户体系是会员,认证方式是通过微信三方标识 openid 认证 MemberUserDetailsServiceImpl memberUserDetailsService = (MemberUserDetailsServiceImpl) userDetailsService; return memberUserDetailsService.loadUserByOpenId(authentication.getName()); } else if (clientId.equals(SecurityConstants.ADMIN_CLIENT_ID)) { // 管理系统的用户体系是系统用户,认证方式通过用户名 username 认证 return userDetailsService.loadUserByUsername(authentication.getName()); } else { return userDetailsService.loadUserByUsername(authentication.getName()); } }
配置授权服务器AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter
@Override protected void configure(HttpSecurity http) throws Exception { if (CollectionUtil.isEmpty(ignoreUrls)) { ignoreUrls = Arrays.asList(\"/webjars/**\
@Bean public WechatAuthenticationProvider wechatAuthenticationProvider() { WechatAuthenticationProvider provider = new WechatAuthenticationProvider(); provider.setUserDetailsService(memberUserDetailsService); provider.setWxMaService(wxMaService); provider.setMemberFeignClient(memberFeignClient); return provider; }
@Bean public DaoAuthenticationProvider daoAuthenticationProvider() { DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); provider.setUserDetailsService(sysUserDetailsService); provider.setPasswordEncoder(passwordEncoder()); provider.setHideUserNotFoundExceptions(false); // 是否隐藏用户不存在异常,默认:true-隐藏;false-抛出异常; return provider; }
@Override public void configure(AuthenticationManagerBuilder auth) { auth.authenticationProvider(wechatAuthenticationProvider()). authenticationProvider(daoAuthenticationProvider()). authenticationProvider(smsCodeAuthenticationProvider()); }
配置安全配置public class WebSecurityConfig extends WebSecurityConfigurerAdapter
@Bean public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http ) { if (CollectionUtil.isEmpty(forbiddenURIs)) { forbiddenURIs = Collections.EMPTY_LIST; } http.authorizeExchange() .pathMatchers(Convert.toStrArray(forbiddenURIs)).denyAll() // 放行交由资源服务器进行认证鉴权 .anyExchange().permitAll() .and() // 禁用csrf token安全校验 .csrf().disable(); return http.build(); }
@Bean public CorsConfigurationSource corsConfigurationSource() { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(new PathPatternParser()); CorsConfiguration corsConfig = new CorsConfiguration(); // 允许所有请求方法 corsConfig.addAllowedMethod(\"*\"); // 允许所有域,当请求头 corsConfig.addAllowedOriginPattern(\"*\"); // 允许全部请求头 corsConfig.addAllowedHeader(\"*\"); // 允许携带 Authorization 头 corsConfig.setAllowCredentials(true); // 允许全部请求路径 source.registerCorsConfiguration(\"/**\
Gateway - OAuth2ClientSecurityConfig
授权中心心得
微服务网关整合 OAuth2.0 思路分析
默认15次,6秒一次
重试进行查询
用RocketMq事务消息改造支付超时
为了保证健壮性,最好加上定时任务进行补偿
支付宝
buffer_pool 需要读取磁盘到内存
buffer_pool_size 缓存参数 物理内存的50 - 80buffer_pool_instances = 4 多线程进行访问分段锁
数据库缓存
Ehcache,Voldemort
应用级缓存
redis、MongoDB
平台级缓存
不考虑,更新缓存成功,数据库异常不一致
先更新缓存,更新DB
先更新DB,更新缓存
更新缓存类
同时有更新和查询时,缓存有可能读的还是旧值
延时双删,先删除,更新后,休眠1s再次删除如果是读写分离,延时的问题
先删除缓存,再更新DB
主要是因为缓存失效了,读取缓存,更新数据库太快删除缓存再缓存失效之前
先更新DB,再删除缓存
删除失败,缓存队列进行删除
订阅binlog日志进行删除,删除失败了再到缓存队列
删除缓存失败,依然是旧值
删除缓存类
最终一致性
缓存一致性问题
首页读取本地CaffineCache本地缓存
首页读取redis服务,没有再想促销服务获取
促销服务读取数据库,缓存至redis
首页服务-促销服务
CommandLineRunner 缓存进行预热
双缓存策略,避免毛刺现象
备份缓存,定时任务直接更新最新
本地缓存预热
redis缓存预热
缓存预热
30分钟过期,定时任务检查任务缓存
首页频繁的点
数据一致性问题
注册用户 - 布隆过滤器
删除缓存
太频繁
也可更新旧数据
携带ACK
canal
项目说明
去中心化服务
集群之间通过gossip
16384 的槽位 网络风暴
redisCluster 大厂一般不使用
手写客户端,自定义路由表
redis
每一步是可逆的, 可以使用binlog实现异构数据库之间读取
1. 支持双写新旧两个库,预留热切换开关,只写旧库,新库,双写
2. 读取开关,如上
3. 上线,只读取旧库,不读取新库,验证数据
更换数据库
高并发缓存
巨大的瞬时流量
热点数据问题
刷子流量
挑战
业务隔离,商品发布流程更改
服务隔离,大流量进行隔离开
数据库隔离,redis主从进行隔离
隔离
将nginx变成一个高性能的web服务openresty.org/cn
master进程监控,worker进程处理
lua虚拟机嵌入nginx管理进程
文件缓存等
多进程的单线程程序
负载均衡和反向代理
nginx 可以直接读取redis
nginx和redis的从服务部署同一台机器
OpenResty
插件
提取prodcut信息到ftl模版进行创建html
sftp进行上次静态网页
静态网页
预约商品才能进行秒杀
内存累加,批量存入redis,无需精准
秒杀前的流量管控
验证码和问答题
秒杀下单,异步下单
消息队列
流量削峰
令牌桶
漏铜
HttpLimiteZone
HttpLimiteRequest
Nginx限流
令牌桶,按照一定速率放
RateLimiter
发放令牌桶,缓存订单id列表进行做限流,只有获取到订单id才能下单
自定义限流
限流
查询和扣减一个事务中, 查询加独占锁,或者Update 加Where语句进行控制不超卖
行锁机制
分布式锁
缓存中进行做扣减
库存扣减
秒杀事中流量管理
容易订单号伪造
定时去生成,增加代码复杂性
1. 获取订单号令牌桶,按照一定速率放的订单号
2. 商品库存是否还有, 没有就记录本地变量
3. 返回订单信息
获取订单
1. 校验订单
使用分布式锁进行锁定
内存先减后加,超卖现象
2. 预减库存
3. 创建订单
4. 发到RocketMQ进行下单, 失败还原库存
1. 扣减真实库存,冻结
2. 发送订单待支付,20分钟内未下单则取消, 延迟消息队列
3. 后续进行支付
5. 获取到消息队列消息
下单
秒杀代码方案流程
秒杀
商品详情页入口流量防护:黑白名单,限制同一个ip访问频率,限制查询商品接口调用频率
https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#the-requestratelimiter-gatewayfilter-factory
gateway官方提供了RequestRateLimiter过滤器工厂,基于redis+lua脚本方式采用令牌桶算法实现了限流。
方案一: 基于redis+lua脚本限流
利用 Sentinel 的网关流控特性,在网关入口处进行流量防护,或限制 API 的调用频率。Spring Cloud Gateway接入Sentinel实现限流的原理:
方案二:整合sentinel限流
网关限流
降级就是当系统的容量达到一定程度时,限制或者关闭系统的某些非核心功能,从而把有限的资源保留给更核心的业务。比如降级方案可以这样设计:当秒杀流量达到 5w/s 时,把成交记录的获取从展示 20 条降级到只展示 5 条。“从 20 改到 5”这个操作由一个开关来实现,也就是设置一个能够从开关系统动态获取的系统参数。降级的核心目标是牺牲次要的功能和用户体验来保证核心业务流程的稳定,是一个不得已而为之的举措。例如在双 11 零点时,如果优惠券系统扛不住,可能会临时降级商品详情的优惠信息展示,把有限的系统资源用在保障交易系统正确展示优惠信息上,即保障用户真正下单时的价格是正确的。
1)开启 Sentinel 对 Feign 的支持:feign: sentinel: enabled: true
2) feign接口配置fallbackFactory@FeignClient(name = \"tulingmall-member\
3) UmsMemberFeginFallbackFactory中编写降级逻辑@Componentpublic class UmsMemberFeginFallbackFactory implements FallbackFactory<UmsMemberFeignApi> { @Override public UmsMemberFeignApi create(Throwable throwable) { return new UmsMemberFeignApi() { @Override public CommonResult<UmsMemberReceiveAddress> getItem(Long id) { //TODO 业务降级 UmsMemberReceiveAddress defaultAddress = new UmsMemberReceiveAddress(); defaultAddress.setName(\"默认地址\"); defaultAddress.setId(-1L); defaultAddress.setDefaultStatus(0); defaultAddress.setPostCode(\"-1\"); defaultAddress.setProvince(\"默认省份\"); defaultAddress.setCity(\"默认city\"); defaultAddress.setRegion(\"默认region\"); defaultAddress.setDetailAddress(\"默认详情地址\"); defaultAddress.setMemberId(-1L); defaultAddress.setPhoneNumber(\"199xxxxxx\"); return CommonResult.success(defaultAddress); } }; } }
OpenFeign整合Sentinel
Sentinel熔断降级
拒绝服务可以说是一种不得已的兜底方案,用以防止最坏情况发生,防止因把服务器压跨而长时间彻底无法提供服务。当系统负载达到一定阈值时,例如 CPU 使用率达到 90% 或者系统 load 值达到 2*CPU 核数时,系统直接拒绝所有请求,这种方式是最暴力但也最有效的系统保护方式。例如秒杀系统,我们可以在以下环节设计过载保护:在最前端的 Nginx 上设置过载保护,当机器负载达到某个值时直接拒绝 HTTP 请求并返回 503 错误码。 阿里针对nginx开发的过载保护扩展插件sysguard: https://github.com/alibaba/nginx-http-sysguard在 Java 层同样也可以设计过载保护。 比如Sentinel提供了系统规则限流
Sentinel系统规则限流Sentinel 系统自适应限流从整体维度对应用入口流量进行控制,结合应用的 Load、CPU 使用率、总体平均 RT、入口 QPS 和并发线程数等几个维度的监控指标,通过自适应的流控策略,让系统的入口流量和系统的负载达到一个平衡,让系统尽可能跑在最大吞吐量的同时保证系统整体的稳定性。Load 自适应(仅对 Linux/Unix-like 机器生效):系统的 load1 作为启发指标,进行自适应系统保护。当系统 load1 超过设定的启发值,且系统当前的并发线程数超过估算的系统容量时才会触发系统保护(BBR 阶段)。系统容量由系统的 maxQps * minRt 估算得出。设定参考值一般是 CPU cores * 2.5。CPU usage(1.5.0+ 版本):当系统 CPU 使用率超过阈值即触发系统保护(取值范围 0.0-1.0),比较灵敏。平均 RT:当单台机器上所有入口流量的平均 RT 达到阈值即触发系统保护,单位是毫秒。并发线程数:当单台机器上所有入口流量的并发线程数达到阈值即触发系统保护。入口 QPS:当单台机器上所有入口流量的 QPS 达到阈值即触发系统保护。系统规则持久化yml配置system-rules: nacos: server-addr: 192.168.65.103:8848 dataId: ${spring.application.name}-system-rules groupId: SENTINEL_GROUP data-type: json rule-type: system
拒绝服务
降级实战
订单兜底方案
UUID优点:性能非常高:本地生成,没有网络消耗。缺点:不易于存储:UUID太长,16字节128位,通常以36长度的字符串表示,很多场景不适用。信息不安全:基于MAC地址生成UUID的算法可能会造成MAC地址泄露,这个漏洞曾被用于寻找梅丽莎病毒的制作者位置。ID作为主键时在特定的环境会存在一些问题,比如做DB主键的场景下,UUID就非常不适用:① MySQL官方有明确的建议主键要尽量越短越好[4],36个字符长度的UUID不符合要求。② 对MySQL索引不利:如果作为数据库主键,在InnoDB引擎下,UUID的无序性可能会引起数据位置频繁变动,严重影响性能。在MySQL InnoDB引擎中使用的是聚集索引,由于多数RDBMS使用B-tree的数据结构来存储索引数据,在主键的选择上面我们应该尽量使用有序的主键保证写入性能。
这种方案大致来说是一种以划分命名空间(UUID也算,由于比较常见,所以单独分析)来生成ID的一种算法,Snowflake 是 Twitter 开源的分布式 ID 生成算法。Snowflake 把64-bit分别划分成多段,分开来标示机器、时间等,比如在snowflake中的64-bit分别表示如下图所示:
Snowflake 优缺点是:优点:毫秒数在高位,自增序列在低位,整个ID都是趋势递增的。不依赖数据库等第三方系统,以服务的方式部署,稳定性更高,生成ID的性能也是非常高的。可以根据自身业务特性分配bit位,非常灵活。缺点:强依赖机器时钟,如果机器上时钟回拨,会导致发号重复或者服务会处于不可用状态。当然,在我们自己的项目如果不想自行实现唯一性ID,还可以利用外部中间件,比如Mongdb objectID,它也可以算作是和snowflake类似方法,通过“时间+机器码+pid+inc”共12个字节,通过4+3+2+3的方式最终标识成一个24长度的十六进制字符。其次Seata内置了一个分布式UUID生成器,用于辅助生成全局事务ID和分支事务ID,我们同样可以拿来使用,完整类名为: io.seata.common.util.IdWorker
雪花算法及其衍生
数据库生成
常见方法介绍
重要字段说明:biz_tag用来区分业务,max_id表示该biz_tag目前所被分配的ID号段的最大值,step表示每次分配的号段长度。原来获取ID每次都需要写数据库,现在只需要把step设置得足够大,比如1000。那么只有当1000个号被消耗完了之后才会去重新读写一次数据库。读写数据库的频率从1减小到了1/step。例如现在有3台机器,每台机器各取1000个,很明显在第一台Leaf机器上是1~1000的号段,当这个号段用完时,会去加载另一个长度为step=1000的号段,假设另外两台号段都没有更新,这个时候第一台机器新加载的号段就应该是3001~4000。同时数据库对应的biz_tag这条数据的max_id会从3000被更新成4000,更新号段的SQL语句如下:
这种模式有以下优缺点:优点:Leaf服务可以很方便的线性扩展,性能完全能够支撑大多数业务场景。ID号码是趋势递增的8byte的64位数字,满足上述数据库存储的主键要求。容灾性高:Leaf服务内部有号段缓存,即使DB宕机,短时间内Leaf仍能正常对外提供服务。可以自定义max_id的大小,非常方便业务从原有的ID方式上迁移过来。缺点:ID号码不够随机,能够泄露发号数量的信息,不太安全。TP999数据波动大,当号段使用完之后还是会在获取新号段时在更新数据库的I/O依然会存在着等待,tg999数据会出现偶尔的尖刺。DB宕机会造成整个系统不可用。
双buffer优化对于第二个缺点,Leaf-segment做了一些优化,简单的说就是:Leaf 取号段的时机是在号段消耗完的时候进行的,也就意味着号段临界点的ID下发时间取决于下一次从DB取回号段的时间,并且在这期间进来的请求也会因为DB号段没有取回来,导致线程阻塞。如果请求DB的网络和DB的性能稳定,这种情况对系统的影响是不大的,但是假如取DB的时候网络发生抖动,或者DB发生慢查询就会导致整个系统的响应时间变慢。为此,希望DB取号段的过程能够做到无阻塞,不需要在DB取号段的时候阻塞请求线程,即当号段消费到某个点时就异步的把下一个号段加载到内存中。而不需要等到号段用尽的时候才去更新号段。这样做就可以很大程度上的降低系统的TP999指标。采用双buffer的方式,Leaf服务内部有两个号段缓存区segment。当前号段已下发10%时,如果下一个号段未更新,则另启一个更新线程去更新下一个号段。当前号段全部下发完后,如果下个号段准备好了则切换到下个号段为当前segment接着下发,循环往复。通常推荐segment长度设置为服务高峰期发号QPS的600倍(10分钟),这样即使DB宕机,Leaf仍能持续发号10-20分钟不受影响。每次请求来临时都会判断下个号段的状态,从而更新此号段,所以偶尔的网络抖动不会影响下个号段的更新。
Leaf高可用容灾对于第三点“DB可用性”问题,可以采用一主两从的方式,同时分机房部署,Master和Slave之间采用半同步方式同步数据。美团内部使用了奇虎360的Atlas数据库中间件(已开源,改名为DBProxy)做主从切换。当然这种方案在一些情况会退化成异步模式,甚至在非常极端情况下仍然会造成数据不一致的情况,但是出现的概率非常小。如果要保证100%的数据强一致,可以选择使用“类Paxos算法”实现的强一致MySQL方案,如MySQL 5.7中的MySQL Group Replication。但是运维成本和精力都会相应的增加,根据实际情况选型即可。
Leaf-segment数据库方案Leaf-segment方案,在使用数据库的方案上,做了如下改变:原MySQL方案每次获取ID都得读写一次数据库,造成数据库压力大。改为批量获取,每次获取一个segment(step决定大小)号段的值。用完之后再去数据库获取新的号段,可以大大的减轻数据库的压力。各个业务不同的发号需求用biz_tag字段来区分,每个biz-tag的ID获取相互隔离,互不影响。如果以后有性能需求需要对数据库扩容,不需要上述描述的复杂的扩容操作,只需要对biz_tag分库分表就行。
弱依赖ZooKeeper除了每次会去ZK拿数据以外,也会在本机文件系统上缓存一个workerID文件。当ZooKeeper出现问题,恰好机器出现问题需要重启时,能保证服务能够正常启动。这样做到了对三方组件的弱依赖。
解决时钟问题因为这种方案依赖时间,如果机器的时钟发生了回拨,那么就会有可能生成重复的ID号,需要解决时钟回退的问题。首先在启动时,服务会进行检查:1、新节点通过检查综合对比其余Leaf节点的系统时间来判断自身系统时间是否准确,具体做法是取所有运行中的Leaf-snowflake节点的服务IP:Port,然后通过RPC请求得到所有节点的系统时间,计算sum(time)/nodeSize,然后看本机时间与这个平均值是否在阈值之内来确定当前系统时间是否准确,准确正常启动服务,不准确认为本机系统时间发生大步长偏移,启动失败并报警。2、在ZooKeeper 中登记过的老节点,同样会比较自身系统时间和ZooKeeper 上本节点曾经的记录时间以及所有运行中的Leaf-snowflake节点的时间,不准确同样启动失败并报警。另外,在运行过程中,每隔一段时间节点都会上报自身系统时间写入ZooKeeper 。
Leaf-snowflake方案Leaf-segment方案可以生成趋势递增的ID,同时ID号是可计算的,不适用于订单ID生成场景,比如竞对在两天中午12点分别下单,通过订单id号相减就能大致计算出公司一天的订单量,这个是不能忍受的。面对这一问题,美团提供了 Leaf-snowflake方案。Leaf-snowflake方案完全沿用snowflake方案的bit位设计,即是“1+41+10+12”的方式组装ID号。对于workerID的分配,当服务集群数量较小的情况下,完全可以手动配置。Leaf服务规模较大,动手配置成本太高。所以使用Zookeeper持久顺序节点的特性自动对snowflake节点配置wokerID。Leaf-snowflake是按照下面几个步骤启动的:启动Leaf-snowflake服务,连接Zookeeper,在leaf_forever父节点下检查自己是否已经注册过(是否有该顺序子节点)。如果有注册过直接取回自己的workerID(zk顺序节点生成的int类型ID号),启动服务。如果没有注册过,就在该父节点下面创建一个持久顺序节点,创建成功后取回顺序号当做自己的workerID号,启动服务。
美团Leaf方案实现
分布式ID微服务
分布式唯一ID实战
1. 30分钟以后过期
2. 访问后的30分钟过期
本地双缓存
1. 定时任务刷新本地缓存
2. 有接口进行更新缓存
首页推荐缓存
Seata架构在 Seata 的架构中,一共有三个角色: TC (Transaction Coordinator) - 事务协调者维护全局和分支事务的状态,驱动全局事务提交或回滚。TM (Transaction Manager) - 事务管理器定义全局事务的范围:开始全局事务、提交或回滚全局事务。RM (Resource Manager) - 资源管理器管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。其中,TC 为单独部署的 Server 服务端,TM 和 RM 为嵌入到应用中的 Client 客户端。
在 Seata 中,一个分布式事务的生命周期如下:TM 请求 TC 开启一个全局事务。TC 会生成一个 XID 作为该全局事务的编号。XID会在微服务的调用链路中传播,保证将多个微服务的子事务关联在一起。RM 请求 TC 将本地事务注册为全局事务的分支事务,通过全局事务的 XID 进行关联。TM 请求 TC 告诉 XID 对应的全局事务是进行提交还是回滚。TC 驱动 RM 们将 XID 对应的自己的本地事务进行提交还是回滚。
1)引入依赖spring-cloud-starter-alibaba-seata内部集成了seata,并实现了xid传递<!--分布式事务 seata依赖--><dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-seata</artifactId> <version>2.2.8.RELEASE</version> <exclusions> <exclusion> <groupId>io.seata</groupId> <artifactId>seata-spring-boot-starter</artifactId> </exclusion> </exclusions></dependency><dependency> <groupId>io.seata</groupId> <artifactId>seata-spring-boot-starter</artifactId> <version>1.5.1</version></dependency>
3)微服务application.yml中添加seata配置#seata 配置seata: application-id: tulingmall-product # seata 服务分组,要与服务端配置service.vgroup_mapping的后缀对应 tx-service-group: tuling-order-group registry: # 指定nacos作为注册中心 type: nacos nacos: application: seata-server server-addr: 192.168.65.103:8848 group: SEATA_GROUP config: # 指定nacos作为配置中心 type: nacos nacos: server-addr: 192.168.65.103:8848 namespace: 7e838c12-8554-4231-82d5-6d93573ddf32 group: SEATA_GROUP data-id: seataServer.properties注意:请确保client与server的注册中心和配置中心namespace和group一致
4)全局事务发起者开启全局事务配置此处是本项目接入seata最难的地方:原因在于订单表用了分库分表技术(shardingsphere),seata不能对逻辑表进行解析。不能简单的在全局事务发起方使用@GlobalTransactional//此处不能使用@GlobalTransactional@GlobalTransactional(name = \"generateOrder\
Seata接入微服务
整合 Seata AT 事务时,需要将 TM,RM 和 TC 的模型融入 Apache ShardingSphere 的分布式事务生态中。 在数据库资源上,Seata 通过对接 DataSource 接口,让 JDBC 操作可以同 TC 进行远程通信。 同样,Apache ShardingSphere 也是面向 DataSource 接口,对用户配置的数据源进行聚合。 因此,将 DataSource 封装为 基于Seata 的 DataSource 后,就可以将 Seata AT 事务融入到 Apache ShardingSphere的分片生态中。
1)引入依赖<!--shardingsphere整合seata依赖--><dependency> <groupId>org.apache.shardingsphere</groupId> <artifactId>sharding-transaction-base-seata-at</artifactId> <version>4.1.1</version></dependency>
2)配置seata.conf包含 Seata 柔性事务的应用启动时,用户配置的数据源会根据 seata.conf 的配置,适配为 Seata 事务所需的 DataSourceProxy,并且注册至 RM 中。client { application.id = tulingmall-order-curr transaction.service.group = tuling-order-group}
ShardingSphere整合Seata
Apache ShardingSphere 分布式事务基于 XA 协议的两阶段事务基于 Seata 的柔性事务
2.2 整合Seata实战Seata分TC、TM和RM三个角色,TC(Server端)为单独服务端部署,TM和RM(Client端)由业务系统集成。
基于Seata实现用户下单冻结库存场景的分布式事务
可靠消息最终一致性方案是指当事务发起执行完成本地事务后并发出一条消息,事务参与方(消息消费者)一定能够接收消息并处理事务成功,此方案强调的是只要消息发给事务参与方最终事务要达到一致。
下面以注册送优惠券为例来说明 :共有两个微服务交互,会员服务和优惠券服务,用户服务负责添加用户,优惠券服务负责赠送优惠券。交互流程如下 :
1)用户注册用户服务在本地事务新增用户和增加“优惠券消息日志”。(用户表和消息表通过本地事务保证一致)下面是伪代码begin transaction;// 1.新增用户// 2.存储优惠券消息日志commit transation;这种情况下,本地数据库操作与存储优惠券消息日志处于同一事务中,本地数据库操作与记录消息日志操作具备原子性。2)定时任务扫描日志如何保证将消息发送给消息队列呢?经过第一步消息已经写到消息日志表中,可以启动独立的线程,定时对消息日志表中的消息进行扫描并发送至消息中间件,在消息中间件反馈发送成功后删除该消息日志,否则等待定时任务下一周期重试。3)消费消息如何保证消费者一定能消费到消息呢?这里可以使用MQ的ack(即消息确认)机制,消费者监听MQ,如果消费者接收到消息并且业务处理完成后向MQ发送ack(即消息确认),此时说明消费者正常消费消息完成,MQ将不再向消费者推送消息,否则消费者会不断重试向消费者来发送消息。优惠券服务接收到“赠送优惠券”消息,开始赠送用户优惠券,成功后消息中间件回应ack,否则消息中间件将重复投递此消息。由于消息会重复投递,优惠券服务的“赠送优惠券”功能需要实现幂等性。
4.1 本地消息表方案本地消息表这个方案最初是eBay提出的,此方案的核心是通过本地事务保证数据业务操作和消息的一致性,然后通过定时任务将消息发送至消息中间件,待确认消息发送给消费方成功再将消息删除。
执行流程如下 :为方便理解我们以注册送优惠券的例子来描述整个流程。Producer即MQ发送方,本例中是用户服务,负责新增用户。MQ订阅方即消息消费方,本例中是优惠券服务,负责新增优惠券。1)Producer发送事务消息Producer(MQ发送方)发送事务消息至MQ Server,MQ Server将消息状态标记为Prepared(预览状态),注意此时这条消息消费者(MQ订阅方)是无法消费到的。2)MQ Server回应消息发送成功MQ Server接收到Producer发送给的消息则回应发送成功表示MQ已接收到消息。3)Producer执行本地事务Producer端执行业务代码逻辑,通过本地数据库事务控制。本例中,Producer执行添加用户操作。4)消息投递若Producer本地事务执行成功则自动向MQ Server发送commit消息,MQ Server接收到commit消息后将“增加优惠券消息”状态标记为可消费,此时MQ订阅方(优惠券服务)即正常消费消息;若Producer 本地事务执行失败则自动向MQ Server发送rollback消息,MQ Server接收到rollback消息后将删除“增加优惠券消息”。MQ订阅方(优惠券服务)消费消息,消费成功则向MQ回应ack,否则将重复接收消息。这里ack默认自动回应,即程序执行正常则自动回应ack。5)事务回查如果执行Producer端本地事务过程中,执行端挂掉,或者超时,MQ Server将会不停的询问同组的其他Producer来获取事务执行状态,这个过程叫事务回查。MQ Server会根据事务回查结果来决定是否投递消息。以上主干流程已由RocketMQ实现,对用户则来说,用户需要分别实现本地事务执行以及本地事务回查方法,因此只需关注本地事务的执行状态即可。
4.2 Rocketmq事务消息实现RocketMQ事务消息设计则主要是为了解决Producer端的消息发送与本地事务执行的原子性问题,RocketMQ的设计中broker与producer端的双向通信能力,使得broker天生可以作为一个事务协调者存在;而RocketMQ本身提供的存储机制为事务消息提供了持久化能力;RocketMQ的高可用机制以及可靠消息设计则为事务消息在系统发生异常时依然能够保证达成事务的最终一致性。在RocketMQ 4.3后实现了完整的事务消息,实际上其实是对本地消息表的一个封装,将本地消息表移动到了MQ内部,解决Producer端的消息发送与本地事务执行的原子性问题。
4. 柔性事务:可靠消息最终一致性方案实现
https://blog.csdn.net/weixin_44102992/article/details/126687205
下单链路分布式事务Seata&MQ可靠消息
本地缓存和集中缓存
Mysql的主从
CDN/静态文件
加缓存/副本
RPC异步
并发读
主流发了微博,通过任务吧微博分发到用户收件箱,写扩散
redis List 限制数量 100页最多
粉丝多的推送给在线的用户
微博Feeds流
将关联表数据结果插入一个宽表
宽表和搜索引擎
重写轻读
高并发读
数据分片
短信注册码登录
写内存,异步write-ahead 日志
异步化
批量写
高并发写
高并发
主库负责执行应用程序发来的数据更新请求,然后将数据变更同步到所有的从库中。这样,主库和所有从库中的数据一致,多个从库可以共同分担应用的查询请求。
读写分离的一个副作用是,可能会存在数据不一致的问题。原因是数据库中的数据在主库完成更新后,是异步同步到每个从库上的,这个过程会有一个微小的时间差。正常情况下,主从延迟非常小,以几毫秒计。但即使是这样小的延迟,也会导致在某个时刻主库和从库上数据不一致的问题。
回顾我们的订单系统业务,用户对购物车发起商品结算创建订单,进入订单页,打开支付页面进行支付,支付完成后,按道理应该再返回到支付之前的订单页。但如果这时马上自动返回到订单页,就很有可能会出现订单状态还是显示“未支付”的问题。因为支付完成后,订单库的主库中订单状态已经更新了,但订单页查询的从库中这条订单记录的状态可能还未更新,如何解决这种问题呢?其实这个问题并没有特别好的技术手段来解决,所以可以看到,稍微上点规模的电商网站并不会支付完成后自动跳到到订单页,而是增加了一个支付完成页面,这个页面其实没有任何新的有效信息,就是告诉你支付成功的信息。如果想再查看一下刚刚支付完成的订单,需要手动选择,这样就能很好地规避主从同步延迟的问题。如果是那些数据更新后需要立刻查询的业务,这两个步骤可以放到一个数据库事务中,同一个事务中的查询操作也会被路由到主库,这样就可以规避主从不一致的问题了,还有一种解决方式则是对查询部分单独指定进行主库查询。总的来说,对于这种因为主从延迟而带来的数据不一致问题,并没有一种简单方便且通用的技术方案可以解决,对此,我们需要重新设计业务逻辑,尽量规避更新数据后立即去从库查询刚刚更新的数据。
读写分离的数据不一致问题
读写分离
如何规划分库分表
分库分表
读写分离与分库分表使用Redis 作为MySQL的前置缓存,可以帮助MySQL挡住绝大部分的查询请求。这种方法对于像电商中的商品系统、搜索系统这类与用户关联不大的系统、效果特别好。因为在这些系统中、任何人看到的内容都是一样的,也就是说,对后端服来说,任何人的查询请求和返回的数据都是一样的。在这种情况下,Redis 缓存的命中率非常高,几乎所有的请求都可以命中缓存。
在设计系统,我们预估订单的数量每个月订单2000W,一年的订单数可达2.4亿。而每条订单的大小大致为1KB,按照我们在MySQL中学习到的知识,为了让B+树的高度控制在一定范围,保证查询的性能,每个表中的数据不宜超过2000W。在这种情况下,为了存下2.4亿的订单,我们似乎应该将订单表分为16(12往上取最近的2的幂)张表。
数据量
既然决定订单系统分库分表,则还有一个重要的问题,那就是如何选择一个合适的列作为分表的依据,该列我们一般称为分片键(Sharding Key)。选择合适的分片键和分片算法非常重要,因为其将直接影响分库分表的效果。
这个问题的解决办法是,在生成订单ID的时候,把用户ID的后几位作为订单ID的一部分。这样按订单ID查询的时候,就可以根据订单ID中的用户ID找到分片。 所以在我们的系统中订单ID从唯一ID服务获取ID后,还会将用户ID的后两位拼接,形成最终的订单ID。
然而,系统对订单的查询万式,肯定不只是按订单ID或按用户ID查询两种方式。比如如果有商家希望查询自家家店的订单,有与订单相关的各种报表。对订单做了分库分表,就没法解决了。这个问题又该怎么解决呢?一般的做法是,把订单里数据同步到其他存储系统中,然后在其他存储系统里解决该问题。比如可以再构建一个以店铺ID作为分片键的只读订单库,专供商家使用。或者数据同步到Hadoop分布式文件系统(HDFS)中,然后通过一些大数据技术生成与订单相关的报表。
选择分片键
商城订单服务的实现
归档历史数据订单数据会随着时间一直累积的数据,前面我们说过预估订单的数量每个月订单2000W,一年的订单数可达2.4亿,三年可达7.2亿。
可以看到在“我的订单”中查询时,分为了近三个月订单、今年内订单、2021年订单、2020年订单等等,这就是典型的将订单数据归档处理。所谓归档,也是一种拆分数据的策略。简单地说,就是把大量的历史订单移到另外一张历史订单表或数据存储中。为什这么做呢?订单数据有个特点:具备时间属性的,并且随着系统的运行,数据累计增长越来越多。但其实订单数据在使用上有个特点,最近的数据使用最频繁,超过一定时间的数据很少使用,这被称之为热尾效应。因为新数据只占数据息量中很少的一部分,所以把新老数据分开之后,新数据的数据量就少很多,查询速度也会因此快很多。虽然与之前的总量相比,老数据没有减少太多,但是因为老数据很少会被访问到,所以即使慢一点儿也不会有太大的问题,而且还可以使用其他的存储系统提升查询速度。
存档历史订单数据
既然是历史订单的归档,归档到哪里去呢?我们可以归档到另外的MySQL数据库,也可以归档到另外的存储系统,这个看自己的业务需求即可,在我们的系统中,我们选择归档到MongoDB数据库。对于数据的迁移归档,我们总是在MySQL中保留3个月的订单数据,超过三个月的数据则迁出。前面我们说过,预估每月订单2000W,一张订单下的商品平均为10个,如果只保留3个月的数据,则订单详情数为6亿,分布到32个表中,每个表容纳的记录数刚好在2000W左右,这也是为什么前面的分库分表将订单表设定为32个的原因。在我们的实现中,OperateDbServiceImpl负责读取MySQL的订单数据和删除已迁出的订单,OperateMgDbServiceImpl负责将订单数据批量插入MongoDB,MigrateCentreServiceImpl负责进行调度服务。
分布式事务?考察迁移的过程,我们是逐表批次删除,对于每张订单表,先从MySQL从获得指定批量的数据,写入MongoDB,再从MySQL中删除已写入MongoDB的部分,这里存在着一个多源的数据操作,为了保证数据的一致性,看起来似乎需要分布式事务。但是其实这里并不需要分布式事务,解决的关键在于写入订单数据到MongoDB时,我们要记住同时写入当前迁入数据的最大订单ID,让这两个操作执行在同一个事务之中。这样,在MySQL执行数据迁移时,总是去MongoDB中获得上次处理的最大OrderId,作为本次迁移的查询起始ID当然数据写入MongoDB后,还要记得删除MySQL中对应的数据。在这个过程中,我们需要注意的问题是,尽量不要影响线上的业务。迁移如此大量的数据,或多或少都会影响数据库的性能,因此应该尽量选择在闲时迁移而且每次数据库操作的记录数不宜太多。按照一般的经验,对MySQL的操作的记录条数每次控制在10000一下是比较合适,在我们的系统中缺省是2000条。更重要的是,迁移之前一定要做好备份,这样的话,即使不小心误操作了,也能用备份来恢复。
商城历史订单服务的实现商城历史订单的归档由tulingmall-order-history服务负责,其中比较关键的是三个Service
MySQL应对海量数据
海量数据优化
性能好,支持事务
WAL + SKipList + 分层有序表
skipList 内存
分层进行合并
布隆过滤器
LSM-TREE
Rocksdb
rocketmq: name-server: 192.168.65.164:9876 consumer: group: stock_consumer_group topic: reduce-stock
业务类中@RocketMQMessageListener指定消费组和topic,也会创建一个消费者@Component@RocketMQMessageListener(consumerGroup = \"${rocketmq.consumer.group}\
源码:RocketMQAutoConfiguration#defaultLitePullConsumer
DefaultLitePullConsumer会用于RocketMQTemplate接收消息
SpringBoot整合Rocketmq的坑,如果在yml中配置了如下配置,会默认创建一个消费者,导致业务类中配置的消费者无法消费部分broker队列的消息
需要进行整合ShardingSphere seata
Apache ShardingSphere 分布式事务
困难难点
@Configuration@Slf4jpublic class ThreadPoolConfig { @Bean public ThreadPoolExecutor threadPoolExecutor() { int cpuCoreSize = Runtime.getRuntime().availableProcessors(); log.info(\"当前CPU核心数:{}\
多线程读取
商品
地址
生成订单id
确认订单
CompletableFuture
用户查看历史记录
zset
lock
getBloom
getClusterBloom
getRatelimiter
获取锁是否大于一半以上
redlock
分布式调度和分布式执行
redisson
项目中使用
项目总结
class,表示Bean类型 scope,表示Bean作用域,单例或原型等 lazyInit:表示Bean是否是懒加载 initMethodName:表示Bean初始化时要执行的方法 destroyMethodName:表示Bean销毁时要执行的方法
BeanDefinition表示Bean定义,BeanDefinition中存在很多属性用来描述一个Bean的特点。比如:
我们还可以编程式定义Bean,那就是直接通过BeanDefinition,比如:
在Spring中,我们经常会通过以下几种方式来定义Bean:
BeanDefinition
可以直接把某个类转换为BeanDefinition,并且会解析该类上的注解。注意:它能解析的注解是:@Conditional,@Scope、@Lazy、@Primary、@DependsOn、 @Role、@Description
AnnotatedBeanDefinitionReader
可以解析<bean/>标签
XmlBeanDefinitionReader
ClassPathBeanDefinitionScanner是扫描器,但是它的作用和BeanDefinitionReader类似,它可以 进行扫描,扫描某个包路径,对扫描到的类进行解析,比如,扫描到的类上如果存在@Component 注解,那么就会把这个类解析为一个BeanDefinition,比如:
ClassPathBeanDefinitionScanner
BeanDefinitionReader
1. AliasRegistry:支持别名功能,一个名字可以对应多个别名2. BeanDefinitionRegistry:可以注册、保存、移除、获取某个BeanDefinition3. BeanFactory:Bean工厂,可以根据某个bean的名字、或类型、或别名获取某个Bean对象4. SingletonBeanRegistry:可以直接注册、获取某个单例Bean5. SimpleAliasRegistry:它是一个类,实现了AliasRegistry接口中所定义的功能,支持别名功能6. ListableBeanFactory:在BeanFactory的基础上,增加了其他功能,可以获取所有 BeanDefinition的beanNames,可以根据某个类型获取对应的beanNames,可以根据某个类 型获取{类型:对应的Bean}的映射关系7. HierarchicalBeanFactory:在BeanFactory的基础上,添加了获取父BeanFactory的功能8. DefaultSingletonBeanRegistry:它是一个类,实现了SingletonBeanRegistry接口,拥有了直 接注册、获取某个单例Bean的功能9. ConfigurableBeanFactory:在HierarchicalBeanFactory和SingletonBeanRegistry的基础上, 添加了设置父BeanFactory、类加载器(表示可以指定某个类加载器进行类的加载)、设置 Spring EL表达式解析器(表示该BeanFactory可以解析EL表达式)、设置类型转化服务(表示 该BeanFactory可以进行类型转化)、可以添加BeanPostProcessor(表示该BeanFactory支持 Bean的后置处理器),可以合并BeanDefinition,可以销毁某个Bean等等功能10. FactoryBeanRegistrySupport:支持了FactoryBean的功能11. AutowireCapableBeanFactory:是直接继承了BeanFactory,在BeanFactory的基础上,支持 在创建Bean的过程中能对Bean进行自动装配12. AbstractBeanFactory:实现了ConfigurableBeanFactory接口,继承了 FactoryBeanRegistrySupport,这个BeanFactory的功能已经很全面了,但是不能自动装配和 获取beanNames13. ConfigurableListableBeanFactory:继承了ListableBeanFactory、 AutowireCapableBeanFactory、ConfigurableBeanFactory14. AbstractAutowireCapableBeanFactory:继承了AbstractBeanFactory,实现了 AutowireCapableBeanFactory,拥有了自动装配的功能15. DefaultListableBeanFactory:继承了AbstractAutowireCapableBeanFactory,实现了 ConfigurableListableBeanFactory接口和BeanDefinitionRegistry接口,所以 DefaultListableBeanFactory的功能很强大
BeanFactory表示Bean工厂,所以很明显,BeanFactory会负责创建Bean,并且提供获取Bean的 API。
BeanFactory
1. ConfigurableApplicationContext:继承了ApplicationContext接口,增加了,添加事件监听 器、添加BeanFactoryPostProcessor、设置Environment,获取 ConfigurableListableBeanFactory等功能2. AbstractApplicationContext:实现了ConfigurableApplicationContext接口3. GenericApplicationContext:继承了AbstractApplicationContext,实现了 BeanDefinitionRegistry接口,拥有了所有ApplicationContext的功能,并且可以注册 BeanDefinition,注意这个类中有一个属性(DefaultListableBeanFactory beanFactory)4. AnnotationConfigRegistry:可以单独注册某个为类为BeanDefinition(可以处理该类上的 **@Configuration注解**,已经可以处理**@Bean注解**),同时可以扫描5. AnnotationConfigApplicationContext:继承了GenericApplicationContext,实现了 AnnotationConfigRegistry接口,拥有了以上所有的功能
AnnotationConfigApplicationContext
它也是继承了AbstractApplicationContext,但是相对于AnnotationConfigApplicationContext而 言,功能没有AnnotationConfigApplicationContext强大,比如不能注册BeanDefinition
ClassPathXmlApplicationContext
上面有分析到,ApplicationContext是个接口,实际上也是一个BeanFactory,不过比BeanFactory 更加强大,比如:1. HierarchicalBeanFactory:拥有获取父BeanFactory的功能2. ListableBeanFactory:拥有获取beanNames的功能3. ResourcePatternResolver:资源加载器,可以一次性获取多个资源(文件资源等等) 4. EnvironmentCapable:可以获取运行时环境(没有设置运行时环境功能)5. ApplicationEventPublisher:拥有广播事件的功能(没有添加事件监听器的功能)6. MessageSource:拥有国际化功能
ApplicationContext
底层架构核心概念
1. 首先,通过ResourcePatternResolver获得指定包路径下的所有 .class 文件(Spring源码中将 此文件包装成了Resource对象)2. 遍历每个Resource对象3. 利用MetadataReaderFactory解析Resource对象得到MetadataReader(在Spring源码中 MetadataReaderFactory具体的实现类为CachingMetadataReaderFactory, MetadataReader的具体实现类为SimpleMetadataReader)4. 利用MetadataReader进行excludeFilters和includeFilters,以及条件注解@Conditional的筛选 (条件注解并不能理解:某个类上是否存在@Conditional注解,如果存在则调用注解中所指定 的类的match方法进行匹配,匹配成功则通过筛选,匹配失败则pass掉。)5. 筛选通过后,基于metadataReader生成ScannedGenericBeanDefinition6. 再基于metadataReader判断是不是对应的类是不是接口或抽象类7. 如果筛选通过,那么就表示扫描到了一个Bean,将ScannedGenericBeanDefinition加入结果集
MetadataReader表示类的元数据读取器,主要包含了一个AnnotationMetadata,功能有1. 获取类的名字、2. 获取父类的名字3. 获取所实现的所有接口名 4. 获取所有内部类的名字 5. 判断是不是抽象类6. 判断是不是接口7. 判断是不是一个注解8. 获取拥有某个注解的方法集合 9. 获取类上添加的所有注解信息10. 获取类上添加的所有注解类型集合值得注意的是,CachingMetadataReaderFactory解析某个.class文件得到MetadataReader对象是 利用的ASM技术,并没有加载这个类到JVM。并且,最终得到的ScannedGenericBeanDefinition对 象,beanClass属性存储的是当前类的名字,而不是class对象。(beanClass属性的类型是Object, 它即可以存储类的名字,也可以存储class对象)最后,上面是说的通过扫描得到BeanDefinition对象,我们还可以通过直接定义BeanDefinition,或 解析spring.xml文件的<bean/>,或者@Bean注解得到BeanDefinition对象。(后续课程会分析 @Bean注解是怎么生成BeanDefinition的)。
1. 生成BeanDefinition
通过扫描得到所有BeanDefinition之后,就可以根据BeanDefinition创建Bean对象了,但是在 Spring中支持父子BeanDefinition,和Java父子类类似,但是完全不是一回事。父子BeanDefinition实际用的比较少,使用是这样的,比如: <bean id=\"parent\" class=\"com.zhouyu.service.Parent\" scope=\"prototype\"/> <bean id=\"child\" class=\"com.zhouyu.service.Child\"/>这么定义的情况下,child是单例Bean。 <bean id=\"parent\" class=\"com.zhouyu.service.Parent\" scope=\"prototype\"/> <bean id=\"child\" class=\"com.zhouyu.service.Child\" parent=\"parent\"/>但是这么定义的情况下,child就是原型Bean了。 因为child的父BeanDefinition是parent,所以会继承parent上所定义的scope属性。而在根据child来生成Bean对象之前,需要进行BeanDefinition的合并,得到完整的child的 BeanDefinition。
2. 合并BeanDefinition
BeanDefinition合并之后,就可以去创建Bean对象了,而创建Bean就必须实例化对象,而实例化就 必须先加载当前BeanDefinition所对应的class,在AbstractAutowireCapableBeanFactory类的 createBean()方法中。如果beanClass属性的类型是Class,那么就直接返回,如果不是,则会根据类名进行加载 (doResolveBeanClass方法所做的事情)
3. 加载类
在Spring中,实例化对象之前,Spring提供了一个扩展点,允许用户来控制是否在某个或某些Bean 实例化之前做一些启动动作。这个扩展点叫 InstantiationAwareBeanPostProcessor.postProcessBeforeInstantiation()。
4. 实例化前
得直接使用BeanDefinition对象来设置Supplier,比如:AbstractBeanDefinition beanDefinition = BeanDefinitionBuilder.genericBeanDefinition().getBeanDefinition();beanDefinition.setInstanceSupplier(new Supplier<Object>() { @Override public Object get() { return new UserService(); }});context.registerBeanDefinition(\"userService\
首先判断BeanDefinition中是否设置了Supplier,如果设置了则调用Supplier的get()得到对象。
5.1 Supplier创建对象
方式一:<bean id=\"userService\" class=\"com.zhouyu.service.UserService\" factory-method=\"createUserService\" />对应的UserService类为:public class UserService { public static UserService createUserService() { System.out.println(\"执行createUserService()\"); UserService userService = new UserService(); return userService; } public void test() { System.out.println(\"test\"); }}方式二:<bean id=\"commonService\" class=\"com.zhouyu.service.CommonService\"/><bean id=\"userService1\" factory-bean=\"commonService\" factory-method=\"createUserService\" />对应的CommonService的类为:public class CommonService { public UserService createUserService() { return new UserService(); }}
如果没有设置Supplier,则检查BeanDefinition中是否设置了factoryMethod,也就是工厂方法,有两种方式可以设置factoryMethod。Spring发现当前BeanDefinition方法设置了工厂方法后,就会区分这两种方式,然后调用工厂方法得到对象。值得注意的是,我们通过@Bean所定义的BeanDefinition,是存在factoryMethod和factoryBean的,也就是和上面的方式二非常类似,@Bean所注解的方法就是factoryMethod,AppConfig对象就是factoryBean。如果@Bean所所注解的方法是static的,那么对应的就是方式一。
5.2 工厂方法创建对象
@Lookup注解就是方法注入,使用demo如下:@Componentpublic class UserService { private OrderService orderService; public void test() { OrderService orderService = createOrderService(); System.out.println(orderService); } @Lookup(\"orderService\") public OrderService createOrderService() { return null; }}
在实例化时,如果判断出来当前BeanDefinition中没有LookupOverride,那就直接用构造方法反射得到一个实例对象。如果存在LookupOverride对象,也就是类中存在@Lookup注解了的方法,那就会生成一个代理对象。
5.3 推断构造方法
5. 实例化在这个步骤中就会根据BeanDefinition去创建一个对象了。
Bean对象实例化出来之后,接下来就应该给对象的属性赋值了。在真正给属性赋值之前,Spring又提供了一个扩展点MergedBeanDefinitionPostProcessor.postProcessMergedBeanDefinition(),可以对此时的BeanDefinition进行加工,在Spring源码中,AutowiredAnnotationBeanPostProcessor就是一个MergedBeanDefinitionPostProcessor,它的postProcessMergedBeanDefinition()中会去查找注入点,并缓存在AutowiredAnnotationBeanPostProcessor对象的一个Map中(injectionMetadataCache)。
6. BeanDefinition的后置处理
在处理完BeanDefinition后,Spring又设计了一个扩展点:InstantiationAwareBeanPostProcessor.postProcessAfterInstantiation(),比如:
7. 实例化后
8. 自动注入、就是属性赋值
这个步骤中,就会处理@Autowired、@Resource、@Value等注解,也是通过**InstantiationAwareBeanPostProcessor.postProcessProperties()**扩展点来实现的,比如我们甚至可以实现一个自己的自动注入功能
9. 处理属性
完成了属性赋值之后,Spring会执行一些回调,包括:BeanNameAware:回传beanName给bean对象。BeanClassLoaderAware:回传classLoader给bean对象。BeanFactoryAware:回传beanFactory给对象。
10. 执行Aware
初始化前,也是Spring提供的一个扩展点:BeanPostProcessor.postProcessBeforeInitialization(),在Spring源码中:InitDestroyAnnotationBeanPostProcessor会在初始化前这个步骤中执行@PostConstruct的方法,ApplicationContextAwareProcessor会在初始化前这个步骤中进行其他Aware的回调:EnvironmentAware:回传环境变量EmbeddedValueResolverAware:回传占位符解析器ResourceLoaderAware:回传资源加载器ApplicationEventPublisherAware:回传事件发布器MessageSourceAware:回传国际化资源ApplicationStartupAware:回传应用其他监听对象,可忽略ApplicationContextAware:回传Spring容器ApplicationContext
11. 初始化前
1. 查看当前Bean对象是否实现了InitializingBean接口,如果实现了就调用其afterPropertiesSet() 方法2. 执行BeanDefinition中指定的初始化方法
12. 初始化
这是Bean创建生命周期中的最后一个步骤,也是Spring提供的一个扩展点:BeanPostProcessor.postProcessAfterInitialization(),
13. 初始化后
InstantiationAwareBeanPostProcessor.postProcessBeforeInstantiation()实例化MergedBeanDefinitionPostProcessor.postProcessMergedBeanDefinition()InstantiationAwareBeanPostProcessor.postProcessAfterInstantiation()自动注入InstantiationAwareBeanPostProcessor.postProcessProperties()Aware对象BeanPostProcessor.postProcessBeforeInitialization()初始化BeanPostProcessor.postProcessAfterInitialization()
总结BeanPostProcessor
1. 当前Bean是否实现了DisposableBean接口2. 或者,当前Bean是否实现了AutoCloseable接口3. BeanDefinition中是否指定了destroyMethod
i. ApplicationListenerDetector中直接使得ApplicationListener是DisposableBeanii. InitDestroyAnnotationBeanPostProcessor中使得拥有@PreDestroy注解了的方法就是 DisposableBean
4. 调用DestructionAwareBeanPostProcessor.requiresDestruction(bean)进行判断
5. 把符合上述任意一个条件的Bean适配成DisposableBeanAdapter对象,并存入 disposableBeans中(一个LinkedHashMap)
在Bean创建过程中,在最后(初始化之后),有一个步骤会去判断当前创建的Bean是不是 DisposableBean:
1. 首先发布ContextClosedEvent事件2. 调用lifecycleProcessor的onCloese()方法
a. 把每个disposableBean从单例池中移除b. 调用disposableBean的destroy()c. 如果这个disposableBean还被其他Bean依赖了,那么也得销毁其他Beand. 如果这个disposableBean还包含了inner beans,将这些Bean从单例池中移除掉 (inner bean参考https://docs.spring.io/spring-framework/docs/current/spring- framework-reference/core.html#beans-inner-beans)
i. 遍历disposableBeans
ii. 清空manualSingletonNames,是一个Set,存的是用户手动注册的单例Bean的 beanName 闭关器容iii. 清空allBeanNamesByType,是一个Map,key是bean类型,value是该类型所有的 beanName数组iv. 清空singletonBeanNamesByType,和allBeanNamesByType类似,只不过只存了单例 Bean
3. 销毁单例Bean
在Spring容器关闭过程时:
Bean的销毁过程
Bean的生命周期
1. name:这个name并不是方法的名字,而是拿方法名字进过处理后的名字
2. readMethodRef:表示get方法的Method对象的引用3. readMethodName:表示get方法的名字4. writeMethodRef:表示set方法的Method对象的引用5. writeMethodName:表示set方法的名字6. propertyTypeRef:如果有get方法那么对应的就是返回值的类型,如果是set方法那么对应的 就是set方法中唯一参数的类型
在创建Bean的过程中,在填充属性时,Spring会去解析当前类,把当前类的所有方法都解析出来, Spring会去解析每个方法得到对应的PropertyDescriptor对象,PropertyDescriptor中有几个属性:
get方法的定义是: 方法参数个数为0个,并且 (方法名字以\"get\"开头 或者 方法名字以\"is\"开头并 且方法的返回类型为boolean)set方法的定义是: 方法参数个数为1个,并且 (方法名字以\"set\"开头并且方法返回类型为 void)
1. 找到所有set方法所对应的XXX部分的名字 2. 根据XXX部分的名字去获取bean
所以,Spring在通过byName的自动填充属性时流程是:
1. 获取到set方法中的唯一参数的参数类型,并且根据该类型去容器中获取bean
2. 如果找到多个,会报错。
Spring在通过byType的自动填充属性时流程是:
如果是constructor,那么就可以不写set方法了,当某个bean是通过构造方法来注入时,spring利用 构造方法的参数信息从Spring容器中去找bean,找到bean之后作为参数传给构造方法,从而实例化 得到一个bean对象,并完成属性赋值(属性赋值的代码得程序员来写)。其实构造方法注入相当于byType+byName,普通的byType是根据set方法中的参数类型去找 bean,找到多个会报错,而constructor就是通过构造方法中的参数类型去找bean,如果找到多个会 根据参数名确定。
以上,分析了autowire的byType和byName情况,那么接下来分析constructor,constructor表示 通过构造方法注入,其实这种情况就比较简单了,没有byType和byName那么复杂。
在创建一个Bean的过程中,Spring会利用AutowiredAnnotationBeanPostProcessor的 **postProcessMergedBeanDefinition()**找出注入点并缓存,找注入点的流程为:1. 遍历当前类的所有的属性字段Field2. 查看字段上是否存在@Autowired、@Value、@Inject中的其中任意一个,存在则认为该字段 是一个注入点3. 如果字段是static的,则不进行注入4. 获取@Autowired中的required属性的值5. 将字段信息构造成一个AutowiredFieldElement对象,作为一个注入点对象添加到 currElements集合中。6. 遍历当前类的所有方法Method7. 判断当前Method是否是桥接方法,如果是找到原方法8. 查看方法上是否存在@Autowired、@Value、@Inject中的其中任意一个,存在则认为该方法 是一个注入点9. 如果方法是static的,则不进行注入10. 获取@Autowired中的required属性的值11. 将方法信息构造成一个AutowiredMethodElement对象,作为一个注入点对象添加到 currElements集合中。12. 遍历完当前类的字段和方法后,将遍历父类的,直到没有父类。13. 最后将currElements集合封装成一个InjectionMetadata对象,作为当前Bean对于的注入点集合 对象,并缓存。
寻找注入点
1. 遍历所有的AutowiredFieldElement对象。2. 将对应的字段封装为DependencyDescriptor对象。3. 调用BeanFactory的resolveDependency()方法,传入DependencyDescriptor对象,进行依 赖查找,找到当前字段所匹配的Bean对象。4. 将DependencyDescriptor对象和所找到的结果对象beanName封装成一个 ShortcutDependencyDescriptor对象作为缓存,比如如果当前Bean是原型Bean,那么下次 再来创建该Bean时,就可以直接拿缓存的结果对象beanName去BeanFactory中去那bean对象 了,不用再次进行查找了5. 利用反射将结果对象赋值给字段。
字段注入
1. 遍历所有的AutowiredMethodElement对象2. 遍历将对应的方法的参数,将每个参数封装成MethodParameter对象3. 将MethodParameter对象封装为DependencyDescriptor对象4. 调用BeanFactory的resolveDependency()方法,传入DependencyDescriptor对象,进行依 赖查找,找到当前方法参数所匹配的Bean对象。5. 将DependencyDescriptor对象和所找到的结果对象beanName封装成一个 ShortcutDependencyDescriptor对象作为缓存,比如如果当前Bean是原型Bean,那么下次 再来创建该Bean时,就可以直接拿缓存的结果对象beanName去BeanFactory中去那bean对象 了,不用再次进行查找了6. 利用反射将找到的所有结果对象传给当前方法,并执行。
Set方法注入
注入点进行注入Spring在AutowiredAnnotationBeanPostProcessor的**postProcessProperties()**方法中,会遍 历所找到的注入点依次进行注入。
@Autowired注解的自动注入 上文说了@Autowired注解,是byType和byName的结合。 @Autowired注解可以写在:1. 属性上:先根据属性类型去找Bean,如果找到多个再根据属性名确定一个2. 构造方法上:先根据方法参数类型去找Bean,如果找到多个再根据参数名确定一个 3. set方法上:先根据方法参数类型去找Bean,如果找到多个再根据参数名确定一个而这种底层到了:1. 属性注入2. set方法注入 3. 构造方法注入
依赖注入
bean的生成步骤如下:1. Spring扫描class得到BeanDefinition2. 根据得到的BeanDefinition去生成bean3. 首先根据class推断构造方法4. 根据推断出来的构造方法,反射,得到一个对象(暂时叫做原始对象)5. 填充原始对象中的属性(依赖注入)6. 如果原始对象中的某个方法被AOP了,那么则需要根据原始对象生成一个代理对象7. 把最终生成的代理对象放入单例池(源码中叫做singletonObjects)中,下次getBean时就直接 从单例池拿即可
三级缓存1. singletonObjects:缓存经过了完整生命周期的bean2. earlySingletonObjects:缓存未经过完整生命周期的bean,如果某个bean出现了循环依赖, 就会提前把这个暂时未经过完整生命周期的bean放入earlySingletonObjects中,这个bean如果 要经过AOP,那么就会把代理对象放入earlySingletonObjects中,否则就是把原始对象放入 earlySingletonObjects,但是不管怎么样,就是是代理对象,代理对象所代理的原始对象也是 没有经过完整生命周期的,所以放入earlySingletonObjects我们就可以统一认为是未经过完整 生命周期的bean。3. singletonFactories:缓存的是一个ObjectFactory,也就是一个Lambda表达式。在每个Bean 的生成过程中,经过实例化得到一个原始对象后,都会提前基于原始对象暴露一个Lambda表达 式,并保存到三级缓存中,这个Lambda表达式可能用到,也可能用不到,如果当前Bean没有出 现循环依赖,那么这个Lambda表达式没用,当前bean按照自己的生命周期正常执行,执行完后 直接把当前bean放入singletonObjects中,如果当前bean在依赖注入时发现出现了循环依赖 (当前正在创建的bean被其他bean依赖了),则从三级缓存中拿到Lambda表达式,并执行 Lambda表达式得到一个对象,并把得到的对象放入二级缓存((如果当前Bean需要AOP,那么 执行lambda表达式,得到就是对应的代理对象,如果无需AOP,则直接得到一个原始对象))。4. 其实还要一个缓存,就是earlyProxyReferences,它用来记录某个原始对象是否进行过AOP 了。
循环依赖
https://www.processon.com/view/link/5f60a7d71e08531edf26a919
下面以AnnotationConfigApplicationContext为例子,来介绍refresh的底层原理。
1. 在调用AnnotationConfigApplicationContext的构造方法之前,会调用父类 GenericApplicationContext的无参构造方法,会构造一个BeanFactory,为 DefaultListableBeanFactory。
i. 设置dependencyComparator:AnnotationAwareOrderComparator,它是一个 Comparator,是用来进行排序的,会获取某个对象上的Order注解或者通过实现Ordered 接口所定义的值进行排序,在日常开发中可以利用这个类来进行排序。ii. 设置autowireCandidateResolver:ContextAnnotationAutowireCandidateResolver, 用来解析某个Bean能不能进行自动注入,比如某个Bean的autowireCandidate属性是否等 于trueiii. 向BeanFactory中添加ConfigurationClassPostProcessor对应的BeanDefinition,它是 一个BeanDefinitionRegistryPostProcessor,并且实现了PriorityOrdered接口iv. 向BeanFactory中添加AutowiredAnnotationBeanPostProcessor对应的 BeanDefinition,它是一个InstantiationAwareBeanPostProcessorAdapter, MergedBeanDefinitionPostProcessorv. 向BeanFactory中添加CommonAnnotationBeanPostProcessor对应的BeanDefinition, 它是一个InstantiationAwareBeanPostProcessor, InitDestroyAnnotationBeanPostProcessorvi. 向BeanFactory中添加EventListenerMethodProcessor对应的BeanDefinition,它是一个 BeanFactoryPostProcessor,SmartInitializingSingletonvii. 向BeanFactory中添加DefaultEventListenerFactory对应的BeanDefinition,它是一个 EventListenerFactory
2. 构造AnnotatedBeanDefinitionReader(主要作用添加一些基础的PostProcessor,同时可以 通过reader进行BeanDefinition的注册),同时对BeanFactory进行设置和添加 PostProcessor(后置处理器)
i. 设置this.includeFilters = AnnotationTypeFilter(Component.class) ii. 设置environmentiii. 设置resourceLoader
3. 构造ClassPathBeanDefinitionScanner(主要作用可以用来扫描得到并注册 BeanDefinition),同时进行设置:
4. 利用reader注册AppConfig为BeanDefinition,类型为AnnotatedGenericBeanDefinition5. 接下来就是调用refresh方法
i. 记录启动时间 ii. 可以允许子容器设置一些内容到Environment中 iii. 验证Environment中是否包括了必须要有的属性
6. prepareRefresh():
7. obtainFreshBeanFactory():进行BeanFactory的refresh,在这里会去调用子类的 refreshBeanFactory方法,具体子类是怎么刷新的得看子类,然后再调用子类的 getBeanFactory方法,重新得到一个BeanFactory
i. 设置beanFactory的类加载器ii. 设置表达式解析器:StandardBeanExpressionResolver,用来解析Spring中的表达式iii. 添加PropertyEditorRegistrar:ResourceEditorRegistrar,PropertyEditor类型转化器注 册器,用来注册一些默认的PropertyEditoriv. 添加一个Bean的后置处理器:ApplicationContextAwareProcessor,是一个 BeanPostProcessor,用来执行EnvironmentAware、ApplicationEventPublisherAware 等回调方法
a. EnvironmentAwareb. EmbeddedValueResolverAware c. ResourceLoaderAwared. ApplicationEventPublisherAware e. MessageSourceAwaref. ApplicationContextAwareg. 另外其实在构造BeanFactory的时候就已经提前添加了另外三个: h. BeanNameAwarei. BeanClassLoaderAwarej. BeanFactoryAware
v. 添加ignoredDependencyInterface:可以向这个属性中添加一些接口,如果某个类实现 了这个接口,并且这个类中的某些set方法在接口中也存在,那么这个set方法在自动注入的 时候是不会执行的,比如EnvironmentAware这个接口,如果某个类实现了这个接口,那么 就必须实现它的setEnvironment方法,而这是一个set方法,和Spring中的autowire是冲突 的,那么Spring在自动注入时是不会调用setEnvironment方法的,而是等到回调Aware接 口时再来调用(注意,这个功能仅限于xml的autowire,@Autowired注解是忽略这个属性 的)
a. BeanFactory.class:当前BeanFactory对象b. ResourceLoader.class:当前ApplicationContext对象c. ApplicationEventPublisher.class:当前ApplicationContext对象 d. ApplicationContext.class:当前ApplicationContext对象
vi. 添加resolvableDependencies:在byType进行依赖注入时,会先从这个属性中根据类型 找bean
vii. 添加一个Bean的后置处理器:ApplicationListenerDetector,是一个 BeanPostProcessor,用来判断某个Bean是不是ApplicationListener,如果是则把这个 Bean添加到ApplicationContext中去,注意一个ApplicationListener只能是单例的viii. 添加一个Bean的后置处理器:LoadTimeWeaverAwareProcessor,是一个 BeanPostProcessor,用来判断某个Bean是不是实现了LoadTimeWeaverAware接口,如果实现了则把ApplicationContext中的loadTimeWeaver回调setLoadTimeWeaver方法设置给该Bean。
a. \"environment\":Environment对象b. \"systemProperties\":System.getProperties()返回的Map对象c. \"systemEnvironment\":System.getenv()返回的Map对象
ix. 添加一些单例bean到单例池:
8. prepareBeanFactory(beanFactory):
9. postProcessBeanFactory(beanFactory) : 提供给AbstractApplicationContext的子类进行扩 展,具体的子类,可以继续向BeanFactory中再添加一些东西
i. 此时在BeanFactory中会存在一个BeanFactoryPostProcessor:ConfigurationClassPostProcessor,它也是一个 BeanDefinitionRegistryPostProcessorii. 第一阶段iii. 从BeanFactory中找到类型为BeanDefinitionRegistryPostProcessor的beanName,也就 是ConfigurationClassPostProcessor, 然后调用BeanFactory的getBean方法得到实例 对象
a. 解析AppConfig类b. 扫描得到BeanDefinition并注册c. 解析@Import,@Bean等注解得到BeanDefinition并注册d. 详细的看另外的笔记,专门分析了ConfigurationClassPostProcessor是如何工作的e. 在这里,我们只需要知道在这一步会去得到BeanDefinition,而这些BeanDefinition中 可能存在BeanFactoryPostProcessor和BeanDefinitionRegistryPostProcessor,所以 执行完ConfigurationClassPostProcessor的postProcessBeanDefinitionRegistry()方 法后,还需要继续执行其他BeanDefinitionRegistryPostProcessor的 postProcessBeanDefinitionRegistry()方法
iv. 执行**ConfigurationClassPostProcessor的postProcessBeanDefinitionRegistry()**方法:
v. 执行其他BeanDefinitionRegistryPostProcessor的 **postProcessBeanDefinitionRegistry()**方法vi. 执行所有BeanDefinitionRegistryPostProcessor的**postProcessBeanFactory()**方法vii. 第二阶段viii. 从BeanFactory中找到类型为BeanFactoryPostProcessor的beanName,而这些 BeanFactoryPostProcessor包括了上面的BeanDefinitionRegistryPostProcessorix. 执行还没有执行过的BeanFactoryPostProcessor的**postProcessBeanFactory()**方法
10. invokeBeanFactoryPostProcessors(beanFactory):执行BeanFactoryPostProcessor
11. 到此,所有的BeanFactoryPostProcessor的逻辑都执行完了,主要做的事情就是得到 BeanDefinition并注册到BeanFactory中12. registerBeanPostProcessors(beanFactory):因为上面的步骤完成了扫描,这个过程中程序员 可能自己定义了一些BeanPostProcessor,在这一步就会把BeanFactory中所有的 BeanPostProcessor找出来并实例化得到一个对象,并添加到BeanFactory中去(属性 beanPostProcessors),最后再重新添加一个ApplicationListenerDetector对象(之前其实 就添加了过,这里是为了把ApplicationListenerDetector移动到最后)13. initMessageSource():如果BeanFactory中存在一个叫做\"messageSource\"的 BeanDefinition,那么就会把这个Bean对象创建出来并赋值给ApplicationContext的 messageSource属性,让ApplicationContext拥有国际化的功能14. initApplicationEventMulticaster():如果BeanFactory中存在一个叫 做\"applicationEventMulticaster\"的BeanDefinition,那么就会把这个Bean对象创建出来并 赋值给ApplicationContext的applicationEventMulticaster属性,让ApplicationContext拥有 事件发布的功能15. onRefresh():提供给AbstractApplicationContext的子类进行扩展,没用16. registerListeners():从BeanFactory中获取ApplicationListener类型的beanName,然后添加 到ApplicationContext中的事件广播器applicationEventMulticaster中去,到这一步因为 FactoryBean还没有调用getObject()方法生成Bean对象,所以这里要在根据类型找一下 ApplicationListener,记录一下对应的beanName17. finishBeanFactoryInitialization(beanFactory):完成BeanFactory的初始化,主要就是实例化 非懒加载的单例Bean,单独的笔记去讲。18. finishRefresh():BeanFactory的初始化完后,就到了Spring启动的最后一步了19. 设置ApplicationContext的lifecycleProcessor,默认情况下设置的是 DefaultLifecycleProcessor20. 调用lifecycleProcessor的onRefresh()方法,如果是DefaultLifecycleProcessor,那么会获取所 有类型为Lifecycle的Bean对象,然后调用它的start()方法,这就是ApplicationContext的生命 周期扩展机制21. 发布ContextRefreshedEvent事件
启动流程
Java Configuration的配置形式注入bean,参考以下代码:@Configurationpublic class FakeConfiguration{ @Bean public FakeService mockService(){ return new FakeServiceImpl(); }}@Configuration标注当前类是Java Config配置类,会被扫描并加载到IoC容器。这边额外简单补充一下 @Configuration 和 @Component的区别:@Component注解的范围最广,所有类都可以注解,而 @Configuration一般注解在这样的类上:这个类里面有 @Value注解的成员变量和@Bean注解的方法,就是一个配置类。英语字面上意义不同,Configuration为配置,Component为组件,都定义在类的上方,也代表着此类声明的意义。
@SpringBootConfiguration根据Javadoc可知,该注解作用就是将当前的类作为一个JavaConfig,然后触发注解@EnableAutoConfiguration和@ComponentScan的处理,本质上与@Configuration注解没有区别。
@EnableScheduling是通过@Import将Spring调度框架相关的bean定义都加载到IoC容器【定时任务、时间调度任务】@EnableMBeanExport是通过@Import将JMX相关的bean定义加载到IoC容器【监控JVM运行时状态】
在spring框架中就提供了各种以@Enable开头的注解,例如: @EnableScheduling、@EnableCaching、@EnableMBeanExport等; @EnableAutoConfiguration的理念和做事方式其实一脉相承简单概括一下就是,借助@Import的支持,收集和注册特定场景相关的bean定义。
注册当前启动类的根 package注册 org.springframework.boot.autoconfigure.AutoConfigurationPackages 的 Bean 的定义。
@AutoConfigurationPackage
可以看到实现了 DeferredImportSelector 接口,该接口继承自 ImportSelector,根据 Javadoc 可知,多用于导入被 @Conditional 注解的Bean,之后会进行 filter 操作AutoConfigurationImportSelector.AutoConfigurationGroup#process 方法,SpringBoot 启动时会调用该方法,进行自动装配 SpringApplication#run(java.lang.String…) SpringApplication#refreshContext(即 Spring IOC 容器初始化的过程中) ConfigurationClassParser#parse AutoConfigurationImportSelector.AutoConfigurationGroup#process
通过 SpringFactoriesLoader#loadFactoryNames 获取应考虑的自动配置名称。通过 filter 过滤掉当前环境不需要自动装配的类,各种 @Conditional 不满足就被过滤掉。将需要自动装配的全路径类名注册到 SpringIOC 容器,自此 SpringBoot 自动装配完成。
@Import(AutoConfigurationImportSelector.class)
@EnableAutoConfiguration此注解顾名思义是可以自动配置,所以应该是springboot中最为重要的注解。@Target({ElementType.TYPE})@Retention(RetentionPolicy.RUNTIME)@Documented@Inherited@AutoConfigurationPackage 【重点注解】@Import({AutoConfigurationImportSelector.class}) 【重点注解】public @interface EnableAutoConfiguration { String ENABLED_OVERRIDE_PROPERTY = \"spring.boot.enableautoconfiguration\"; Class<?>[] exclude() default {}; String[] excludeName() default {};}
ComponentScan的功能其实就是自动扫描并加载符合条件的组件(比如@Component和@Repository等)或者bean定义。我们可以通过 basePackages 等属性来细粒度的定制 @ComponentScan 自动扫描的范围,如果不指定,则默认Spring框架实现会从声明 @ComponentScan 所在类的package进行扫描,所以 SpringBoot 的启动类最好是放在根package下,我们自定义的类就放在对应的子package下,这样就可以不指定 basePackages。
@ComponentScan
在其中比较重要的有三个注解,分别是:@SpringBootConfiguration@EnableAutoConfiguration@ComponentScan
注解
SpringApplication#run
1. 首先会实例化SpringApplication一个对象。
2. 在构造方法里初始化一些属性,比如webApplicationType,比如\"SERVLET\",初始化一些listeners。
实例化SpringApplication对象
源码
创建一个StopWatch实例,用来记录SpringBoot的启动时间。通过SpringFactoriesLoader加载listeners:比如EventPublishingRunListener。发布SprintBoot开始启动事件(EventPublishingRunListener#starting())。创建和配置environment(environmentPrepared())。打印SpringBoot的banner和版本。创建对应的ApplicationContext:Web类型,Reactive类型,普通的类型(非Web)。
1. 填充环境属性
2. 后置处理 添加一些属性 最重要的就是添加conversionService
1. 通过DelegatingApplicationContextInitializer可以在刷新容器之前进行一些扩展,比如添加BeanFactoryPostProcessor。
2. SharedMetadataReaderFactoryContextInitializer
ContextIdApplicationContextInitializer
ConfigurationWarningsApplicationContextInitializer
ServerPortInfoApplicationContextInitializer
ConditionEvaluationReportLoggingListener
3. ApplicationContextInitializer扩展
4. 发布ApplicationContextInitializedEvent事件
5. 注册单例Bean
protected void prepareRefresh() { //记录容器启动时间,然后设立对应的标志位 this.startupDate = System.currentTimeMillis(); this.closed.set(false); this.active.set(true); // 打印info日志:开始刷新this此容器了 if (logger.isInfoEnabled()) { logger.info(\"Refreshing \
容器刷新前的准备,设置上下文状态,获取属性,验证必要的属性等
第一步:prepareRefresh()
@Override protected final void refreshBeanFactory() throws BeansException { // 判断是否已经存在BeanFactory,存在则销毁所有Beans,并且关闭BeanFactory // 避免重复加载BeanFactory if (hasBeanFactory()) { destroyBeans(); closeBeanFactory(); } try { // 创建具体的beanFactory,这里创建的是DefaultListableBeanFactory,最重要的beanFactory spring注册及加载bean就靠它 // createBeanFactory()这个方法,看下面,还有得说的 DefaultListableBeanFactory beanFactory = createBeanFactory(); beanFactory.setSerializationId(getId()); // 这句比较简单,就是把当前旧容器的一些配置值复制给新容器 // allowBeanDefinitionOverriding属性是指是否允对一个名字相同但definition不同进行重新注册,默认是true。 // allowCircularReferences属性是指是否允许Bean之间循环引用,默认是true. // 这两个属性值初始值为空:复写此方法即可customizeBeanFactory customizeBeanFactory(beanFactory); // 这个就是最重要的了,加载所有的Bean配置信息,具体如下详细解释 // 它属于模版方法,由子类去实现加载的方式 loadBeanDefinitions(beanFactory); synchronized (this.beanFactoryMonitor) { this.beanFactory = beanFactory; } } catch (IOException ex) { throw new ApplicationContextException(\"I/O error parsing bean definition source for \
实际上就是重新创建一个bean工厂,并销毁原工厂。主要工作是创建DefaultListableBeanFactory实例,解析配置文件,注册Bean的定义信息
第二步:ConfigurableListableBeanFactory beanFactory = obtainFreshBeanFactory()
配置标准的beanFactory,设置ClassLoader,设置SpEL表达式解析器等
第三步:prepareBeanFactory(beanFactory)
模板方法,允许在子类中对beanFactory进行后置处理。
第四步:postProcessBeanFactory(beanFactory)
invokeBeanFactoryPostProcessors执行BeanFactory后置处理器,当然前提是你已经在容器中注册过此处理器了。
invokeBeanFactoryPostProcessors(beanFactory)这一步就完成了。这一步主要做了:执行了BeanDefinitionRegistryPostProcessor(此处只有ConfigurationClassPostProcessor)执行了BeanFactoryPostProcessor完成了@Configuration配置文件的解析,并且把扫描到的、配置的Bean定义信息都加载进容器里Full模式下,完成了对@Configuration配置文件的加强,使得管理Bean依赖关系更加的方便了
扫描加载了所有的beanDefine
第五步:invokeBeanFactoryPostProcessors(beanFactory)
处理autowire
configure 属性
AOP
//AutowiredAnnotationBeanPostProcessor(处理被@Autowired注解修饰的bean并注入) //RequiredAnnotationBeanPostProcessor(处理被@Required注解修饰的方法) //CommonAnnotationBeanPostProcessor(处理@PreDestroy、@PostConstruct、@Resource等多个注解的作用)等。
注册bean后置处理器
第六步:registerBeanPostProcessors(beanFactory)
//初始化国际化工具类MessageSource
第七步:initMessageSource()
第八步:initApplicationEventMulticaster()
//模板方法,在容器刷新的时候可以自定义逻辑(子类自己去实现逻辑),不同的Spring容器做不同的事情
类似于第四步的postProcessBeanFactory,它也是个模版方法。本环境中的实现为:AbstractRefreshableWebApplicationContext#onRefresh方法:
启动web服务
第九步:onRefresh()
我们知道,上面我们已经把事件源、多播器都注册好了,这里就是注册监听器了:
第十步:registerListeners();
实例化bean
接下来重点看看DefaultListableBeanFactory#preInstantiateSingletons:实例化所有剩余的单例Bean
第十一步:finishBeanFactoryInitialization(beanFactory)
当ApplicationContext启动或停止时,它会通过LifecycleProcessor来与所有声明的bean的周期做状态更新,而在LifecycleProcessor的使用前首先需要初始化
initLifecycleProcessor():
refresh() 第十二步:finishRefresh()
最终会调用到AbstractApplicationContext#refresh方法,实际上就是Spring IOC容器的创建过程,并且会进行自动装配的操作以及发布ApplicationContext已经refresh事件,标志着ApplicationContext初始化完成
refreshContext
8. afterRefresh hook方法。9. stopWatch停止计时,日志打印总共启动的时间。10. 发布SpringBoot程序已启动事件(started())。11. 调用ApplicationRunner和CommandLineRunner。12. 最后发布就绪事件ApplicationReadyEvent,标志着SpringBoot可以处理就收的请求了(running())。
SpringApplication.run(java.lang.String…)
main方法
SpringApplicationRunListeners的唯一实现是EventPublishingRunListener。整个SpringBoot的启动,流程就是各种事件的发布,调用EventPublishingRunListener中的方法。只要明白了EventPublishingRunListener中事件发布的流程,也就明白了SpringBoot启动的大体流程。
EventPublishingRunListener
SpringBoot启动事件
启动流程自解篇
1.如果项目依赖中存在org.springframework.web.reactive.DispatcherHandler,并且不存在org.springframework.web.servlet.DispatcherServlet,那么应用类型为WebApplicationType.REACTIVE2.如果项目依赖中不存在org.springframework.web.reactive.DispatcherHandler,也不存在org.springframework.web.servlet.DispatcherServlet,那么应用类型为WebApplicationType.NONE3.否则,应用类型为WebApplicationType.SERVLET
1、推测web应用类型
1.从\"META-INF/spring.factories\"中读取key为BootstrapRegistryInitializer类型的扩展点,并实例化出对应扩展点对象2.BootstrapRegistryInitializer的作用是可以初始化BootstrapRegistry3.上面的DefaultBootstrapContext对象就是一个BootstrapRegistry,可以用来注册一些对象,这些对象可以用在从SpringBoot启动到Spring容器初始化完成的过程中4.我的理解:没有Spring容器之前就利用BootstrapRegistry来共享一些对象,有了Spring容器之后就利用Spring容器来共享一些对象
2、获取BootstrapRegistryInitializer对象
1.从\"META-INF/spring.factories\"中读取key为ApplicationContextInitializer类型的扩展点,并实例化出对应扩展点对象2.顾名思义,ApplicationContextInitializer是用来初始化Spring容器ApplicationContext对象的,比如可以利用ApplicationContextInitializer来向Spring容器中添加ApplicationListener
3、获取ApplicationContextInitializer对象
1.从\"META-INF/spring.factories\"中读取key为ApplicationListener类型的扩展点,并实例化出对应扩展点对象2.ApplicationListener是Spring中的监听器,并不是SpringBoot中的新概念,不多解释了
4、获取ApplicationListener对象
没什么具体的作用,逻辑是根据当前线程的调用栈来判断main()方法在哪个类,哪个类就是Main类
5、推测出Main类(main()方法所在的类)
构造SpringApplication对象
1.创建DefaultBootstrapContext对象2.利用BootstrapRegistryInitializer初始化DefaultBootstrapContext对象3.获取SpringApplicationRunListeners这三个步骤没什么特殊的
5、触发SpringApplicationRunListener的starting()默认情况下SpringBoot提供了一个EventPublishingRunListener,它实现了SpringApplicationRunListener接口,默认情况下会利用EventPublishingRunListener发布一个ApplicationContextInitializedEvent事件,程序员可以通过定义ApplicationListener来消费这个事件
Environment对象表示环境变量,该对象内部主要包含了:1.当前操作系统的环境变量2.JVM的一些配置信息3.-D方式所配置的JVM环境变量
6、创建Environment对象
默认情况下会利用EventPublishingRunListener发布一个ApplicationEnvironmentPreparedEvent事件,程序员可以通过定义ApplicationListener来消费这个事件,比如默认情况下会有一个EnvironmentPostProcessorApplicationListener来消费这个事件,而这个ApplicationListener接收到这个事件之后,就会解析application.properties、application.yml文件,并添加到Environment对象中去。
7、触发SpringApplicationRunListener的environmentPrepared()
8、打印Banner
ApplicationContextFactory DEFAULT = (webApplicationType) -> { try { switch (webApplicationType) { case SERVLET: return new AnnotationConfigServletWebServerApplicationContext(); case REACTIVE: return new AnnotationConfigReactiveWebServerApplicationContext(); default: return new AnnotationConfigApplicationContext(); } } catch (Exception ex) { throw new IllegalStateException(\
会利用ApplicationContextFactory.DEFAULT,根据应用类型创建对应的Spring容器。ApplicationContextFactory.DEFAULT为
所以:1.应用类型为SERVLET,则对应AnnotationConfigServletWebServerApplicationContext2.应用类型为REACTIVE,则对应AnnotationConfigReactiveWebServerApplicationContext3.应用类型为普通类型,则对应AnnotationConfigApplicationContext
9、创建Spring容器对象(ApplicationContext)
默认情况下SpringBoot提供了多个ApplicationContextInitializer,其中比较重要的有ConditionEvaluationReportLoggingListener,别看到它的名字叫XXXListener,但是它确实是实现了ApplicationContextInitializer接口的。
在它的initialize()方法中会:1.将Spring容器赋值给它的applicationContext属性2.并且往Spring容器中添加一个ConditionEvaluationReportListener(ConditionEvaluationReportLoggingListener的内部类),它是一个ApplicationListener3.并生成一个ConditionEvaluationReport对象赋值给它的report属性
ConditionEvaluationReportListener会负责接收ContextRefreshedEvent事件,也就是Spring容器一旦启动完毕就会触发ContextRefreshedEvent,ConditionEvaluationReportListener就会打印自动配置类的条件评估报告。
10、利用ApplicationContextInitializer初始化Spring容器对象
11、触发SpringApplicationRunListener的contextPrepared()默认情况下会利用EventPublishingRunListener发布一个ApplicationContextInitializedEvent事件,默认情况下暂时没有ApplicationListener消费了这个事件
12、调用DefaultBootstrapContext对象的close()
13、将启动类作为配置类注册到Spring容器中(load()方法)将SpringApplication.run(MyApplication.class);中传入进来的类,比如MyApplication.class,作为Spring容器的配置类
14、 触发SpringApplicationRunListener的contextLoaded()默认情况下会利用EventPublishingRunListener发布一个ApplicationPreparedEvent事件
调用Spring容器的refresh()方法,结合第9、13步,相当于执行了这样一个流程:1.AnnotationConfigApplicationContext applicationContext = new AnnotationConfigApplicationContext();2.applicationContext .register(MyApplication.class)3.applicationContext .refresh()
15、刷新Spring容器
发布ApplicationStartedEvent事件和AvailabilityChangeEvent事件,AvailabilityChangeEvent事件表示状态变更状态,变更后的状态为LivenessState.CORRECT
LivenessState枚举有两个值:1.CORRECT:表示当前应用正常运行中2.BROKEN:表示当前应用还在运行,但是内部出现问题,暂时还没发现哪里用到了
16、触发SpringApplicationRunListener的started()
1.获取Spring容器中的ApplicationRunner类型的Bean2.获取Spring容器中的CommandLineRunner类型的Bean3.执行它们的run()
17、调用ApplicationRunner和CommandLineRunner
发布ApplicationReadyEvent事件和AvailabilityChangeEvent事件,AvailabilityChangeEvent事件表示状态变更状态,变更后的状态为ReadinessState.ACCEPTING_TRAFFIC
ReadinessState枚举有两个值:1.ACCEPTING_TRAFFIC:表示当前应用准备接收请求2.REFUSING_TRAFFIC:表示当前应用拒绝接收请求,比如Tomcat关闭时,就会发布AvailabilityChangeEvent事件,并且状态为REFUSING_TRAFFIC
18、触发SpringApplicationRunListener的ready()
19、上述过程抛异常了就触发SpringApplicationRunListener的failed()发布ApplicationFailedEvent事件
run(String... args)方法
启动过程
ProxyFactory通过ProxyFactory,我们可以不再关系到底是用cglib还是jdk动态代理了,ProxyFactory会帮我们去 判断,如果UserService实现了接口,那么ProxyFactory底层就会用jdk动态代理,如果没有实现接 口,就会用cglib技术,上面的代码,就是由于UserService实现了UserInterface接口,所以最后产生 的代理对象是UserInterface类型。
Advice的分类1. Before Advice:方法之前执行2. After returning advice:方法return后执行3. After throwing advice:方法抛异常后执行4. After (finally) advice:方法执行完finally之后执行,这是最后的,比return更后 5. Around advice:这是功能最强大的Advice,可以自定义执行顺序
1. Aspect:表示切面,比如被@Aspect注解的类就是切面,可以在切面中去定义Pointcut、 Advice等等2. Join point:表示连接点,表示一个程序在执行过程中的一个点,比如一个方法的执行,比如一 个异常的处理,在Spring AOP中,一个连接点通常表示一个方法的执行。3. Advice:表示通知,表示在一个特定连接点上所采取的动作。Advice分为不同的类型,后面详 细讨论,在很多AOP框架中,包括Spring,会用Interceptor拦截器来实现Advice,并且在连接 点周围维护一个Interceptor链4. Pointcut:表示切点,用来匹配一个或多个连接点,Advice与切点表达式是关联在一起的, Advice将会执行在和切点表达式所匹配的连接点上5. Introduction:可以使用@DeclareParents来给所匹配的类添加一个接口,并指定一个默认实现6. Target object:目标对象,被代理对象7. AOP proxy:表示代理工厂,用来创建代理对象的,在Spring Framework中,要么是JDK动态 代理,要么是CGLIB代理8. Weaving:表示织入,表示创建代理对象的动作,这个动作可以发生在编译时期(比如 Aspejctj),或者运行时,比如Spring AOP
代理对象执行过程1. 在使用ProxyFactory创建代理对象之前,需要往ProxyFactory先添加Advisor2. 代理对象在执行某个方法时,会把ProxyFactory中的Advisor拿出来和当前正在执行的方法进行 匹配筛选3. 把和方法所匹配的Advisor适配成MethodInterceptor4. 把和当前方法匹配的MethodInterceptor链,以及被代理对象、代理对象、代理类、当前 Method对象、方法参数封装为MethodInvocation对象5. 调用MethodInvocation的proceed()方法,开始执行各个MethodInterceptor以及被代理对象 的对应方法6. 按顺序调用每个MethodInterceptor的invoke()方法,并且会把MethodInvocation对象传入 invoke()方法7. 直到执行完最后一个MethodInterceptor了,就会调用invokeJoinpoint()方法,从而执行被代 理对象的当前方法
一个Bean在执行Bean的创建生命周期时,会经过InfrastructureAdvisorAutoProxyCreator的初始 化后的方法,会判断当前当前Bean对象是否和BeanFactoryTransactionAttributeSourceAdvisor匹 配,匹配逻辑为判断该Bean的类上是否存在@Transactional注解,或者类中的某个方法上是否存在 @Transactional注解,如果存在则表示该Bean需要进行动态代理产生一个代理对象作为Bean对象。该代理对象在执行某个方法时,会再次判断当前执行的方法是否和 BeanFactoryTransactionAttributeSourceAdvisor匹配,如果匹配则执行该Advisor中的 TransactionInterceptor的invoke()方法,执行基本流程为: 1. 利用所配置的PlatformTransactionManager事务管理器新建一个数据库连接2. 修改数据库连接的autocommit为false3. 执行MethodInvocation.proceed()方法,简单理解就是执行业务方法,其中就会执行sql 4. 如果没有抛异常,则提交5. 如果抛了异常,则回滚
事务
MyBatis是一个持久层的ORM框架,使用简单,学习成本较低。可以执行自己手 写的SQL语句,比较灵活。但是MyBatis的自动化程度不高,移植性也不高,有 时从一个数据库迁移到另外一个数据库的时候需要自己修改配置,所以称只为半 自动ORM框架
Mybatis-Spring 1.3.2版本底层源码执行流程1. 通过@MapperScan导入了MapperScannerRegistrar类2. MapperScannerRegistrar类实现了ImportBeanDefinitionRegistrar接口,所以Spring在启动 时会调用MapperScannerRegistrar类中的registerBeanDefinitions方法3. 在registerBeanDefinitions方法中定义了一个ClassPathMapperScanner对象,用来扫描 mapper4. 设置ClassPathMapperScanner对象可以扫描到接口,因为在Spring中是不会扫描接口的5. 同时因为ClassPathMapperScanner中重写了isCandidateComponent方法,导致 isCandidateComponent只会认为接口是备选者Component6. 通过利用Spring的扫描后,会把接口扫描出来并且得到对应的BeanDefinition7. 接下来把扫描得到的BeanDefinition进行修改,把BeanClass修改为MapperFactoryBean,把 AutowireMode修改为byType8. 扫描完成后,Spring就会基于BeanDefinition去创建Bean了,相当于每个Mapper对应一个 FactoryBean9. 在MapperFactoryBean中的getObject方法中,调用了getSqlSession()去得到一个sqlSession 对象,然后根据对应的Mapper接口生成一个Mapper接口代理对象,这个代理对象就成为 Spring容器中的Bean10. sqlSession对象是Mybatis中的,一个sqlSession对象需要SqlSessionFactory来产生11. MapperFactoryBean的AutowireMode为byType,所以Spring会自动调用set方法,有两个set 方法,一个setSqlSessionFactory,一个setSqlSessionTemplate,而这两个方法执行的前提是 根据方法参数类型能找到对应的bean,所以Spring容器中要存在SqlSessionFactory类型的 bean或者SqlSessionTemplate类型的bean。12. 如果你定义的是一个SqlSessionFactory类型的bean,那么最终也会被包装为一个 SqlSessionTemplate对象,并且赋值给sqlSession属性13. 而在SqlSessionTemplate类中就存在一个getMapper方法,这个方法中就产生一个Mapper接 口代理对象14. 到时候,当执行该代理对象的某个方法时,就会进入到Mybatis框架的底层执行流程,详细的请 看下图
Mybatis-Spring 2.0.6版本(最新版)底层源码执行流程1. 通过@MapperScan导入了MapperScannerRegistrar类2. MapperScannerRegistrar类实现了ImportBeanDefinitionRegistrar接口,所以Spring在启动 时会调用MapperScannerRegistrar类中的registerBeanDefinitions方法3. 在registerBeanDefinitions方法中注册一个MapperScannerConfigurer类型的 BeanDefinition4. 而MapperScannerConfigurer实现了BeanDefinitionRegistryPostProcessor接口,所以 Spring在启动过程中时会调用它的postProcessBeanDefinitionRegistry()方法5. 在postProcessBeanDefinitionRegistry方法中会生成一个ClassPathMapperScanner对象,然 后进行扫描6. 后续的逻辑和1.3.2版本一样。
SqlSessionFactoryBuilder解析配置文件,包括属性配置、别名配置、拦截器配置、环境(数据源和事务管理器)、Mapper配置等;解析完这些配置后会生成一个Configration对象,这个对象中包含了MyBatis需要的所有配置,然后会用这个Configration对象创建一个SqlSessionFactory对象,这个对象中包含了Configration对象;
Spring整合Mybatis后一级缓存失效问题Mybatis中的一级缓存是基于SqlSession来实现的,所以在执行同一个sql时,如果使用的是同一个 SqlSession对象,那么就能利用到一级缓存,提高sql的执行效率。但是在Spring整合Mybatis后,如果没有执行某个方法时,该方法上没有加@Transactional注解,也 就是没有开启Spring事务,那么后面在执行具体sql时,没执行一个sql时都会新生成一个SqlSession 对象来执行该sql,这就是我们说的一级缓存失效(也就是没有使用同一个SqlSession对象),而如 果开启了Spring事务,那么该Spring事务中的多个sql,在执行时会使用同一个SqlSession对象,从 而一级缓存生效,具体的底层执行流程在上图。
SynchronizedCache线程同步缓存区实现线程同步功能,与序列化缓存区共同保证二级缓存线程安全。若blocking=false关闭则SynchronizedCache位于责任链的最前端,否则就位于BlockingCache后面而BlockingCache位于责任链的最前端,从而保证了整条责任链是线程同步的。
LoggingCache统计命中率以及打印日志统计二级缓存命中率并输出打印,由以下源码可知:日志中出现了“Cache Hit Ratio”便表示命中了二级缓存。
ScheduledCache过期清理缓存区@CacheNamespace(flushInterval=100L)设置过期清理时间默认1个小时,若设置flushInterval为0代表永远不进行清除。
LruCache(最近最少使用)防溢出缓存区内部使用链表(增删比较快)实现最近最少使用防溢出机制
FifoCache(先进先出)防溢出缓存区源码分析:内部使用队列存储key实现先进先出防溢出机制。
二级缓存在结构设计上采用装饰器+责任链模式
二级缓存也称作是应用级缓存,与一级缓存不同的是它的作用范围是整个应用,而且可以跨线程使用。所以二级缓存有更高的命中率,适合缓存一些修改比较少的数据。二级缓存的生命周期是整个应用,所以必须限制二级缓存的容量,在这里mybatis使用的是溢出淘汰机制。而一级缓存是会话级的生命周期非常短暂是没有必要实现这些功能的。相比较之下,二级缓存机制更加完善。
设计模式Builder模式,例如SqlSessionFactoryBuilder、XMLConfigBuilder、XMLMapperBuilder、XMLStatementBuilder、CacheBuilder;工厂模式,例如SqlSessionFactory、ObjectFactory、MapperProxyFactory;单例模式,例如ErrorContext和LogFactory;代理模式,Mybatis实现的核心,比如MapperProxy、ConnectionLogger,用的jdk的动态代理;还有executor.loader包使用了cglib或者javassist达到延迟加载的效果;组合模式,例如SqlNode和各个子类ChooseSqlNode等;模板方法模式,例如BaseExecutor和SimpleExecutor,还有BaseTypeHandler和所有的子类例如IntegerTypeHandler;适配器模式,例如Log的Mybatis接口和它对jdbc、log4j等各种日志框架的适配实现;装饰者模式,例如Cache包中的cache.decorators子包中等各个装饰者的实现;迭代器模式,例如迭代器模式PropertyTokenizer;
重要类MapperRegistry:本质上是一个Map,其中的key是Mapper接口的全限定名,value的MapperProxyFactory;MapperProxyFactory:这个类是MapperRegistry中存的value值,在通过sqlSession获取Mapper时,其实先获取到的是这个工厂,然后通过这个工厂创建Mapper的动态代理类;MapperProxy:实现了InvocationHandler接口,Mapper的动态代理接口方法的调用都会到达这个类的invoke方法;MapperMethod:判断你当前执行的方式是增删改查哪一种,并通过SqlSession执行相应的操作;SqlSession:作为MyBatis工作的主要顶层API,表示和数据库交互的会话,完成必要数据库增删改查功能;Executor:MyBatis执行器,是MyBatis 调度的核心,负责SQL语句的生成和查询缓存的维护;StatementHandler:封装了JDBC Statement操作,负责对JDBC statement 的操作,如设置参数、将Statement结果集转换成List集合。ParameterHandler:负责对用户传递的参数转换成JDBC Statement 所需要的参数,ResultSetHandler:负责将JDBC返回的ResultSet结果集对象转换成List类型的集合;TypeHandler:负责java数据类型和jdbc数据类型之间的映射和转换MappedStatement:MappedStatement维护了一条<select|update|delete|insert>节点的封装,SqlSource:负责根据用户传递的parameterObject,动态地生成SQL语句,将信息封装到BoundSql对象中,并返回BoundSql:表示动态生成的SQL语句以及相应的参数信息Configuration:MyBatis所有的配置信息都维持在Configuration对象之中。调试主要关注点MapperProxy.invoke方法:MyBatis的所有Mapper对象都是通过动态代理生成的,任何方法的调用都会调到invoke方法,这个方法的主要功能就是创建MapperMethod对象,并放进缓存。所以调试时我们可以在这个位置打个断点,看下是否成功拿到了MapperMethod对象,并执行了execute方法。MapperMethod.execute方法:这个方法会判断你当前执行的方式是增删改查哪一种,并通过SqlSession执行相应的操作。Debug时也建议在此打个断点看下。DefaultSqlSession.selectList方法:这个方法获取了获取了MappedStatement对象,并最终调用了Executor的query方法;
sql流程
Mybatis
Spring、SpringBoot
Nacos 是 Dynamic Naming and Configuration Service 的首字母简称;一个更易于构建云原生应用的动态服务发现、配置管理和服务管理平台。Nacos 致力于帮助您发现、配置和管理微服务。Nacos 提供了一组简单易用的特性集,帮助您快速实现动态服务发现、服务配置、服务元数据及流量管理。Nacos 的关键特性包括:服务发现和服务健康监测动态配置服务动态 DNS 服务服务及其元数据管理
Nacos 优势 易用:简单的数据模型,标准的 restfulAPI,易用的控制台,丰富的使用文档。稳定:99.9% 高可用,脱胎于历经阿里巴巴 10 年生产验证的内部产品,支持具有数百万服务的大规模场景,具备企业级 SLA 的开源产品。 实时:数据变更毫秒级推送生效;1w 级,SLA 承诺 1w 实例上下线 1s,99.9% 推送完成;10w 级,SLA 承诺 1w 实例上下线 3s,99.9% 推送完成;100w 级别,SLA 承诺 1w 实例上下线 9s 99.9% 推送完成。 规模:十万级服务/配置,百万级连接,具备强大扩展性。
服务 (Service)服务是指一个或一组软件功能(例如特定信息的检索或一组操作的执行),其目的是不同的客户端可以为不同的目的重用(例如通过跨进程的网络调用)。Nacos 支持主流的服务生态,如 Kubernetes Service、gRPC|Dubbo RPC Service 或者 Spring Cloud RESTful Service。服务注册中心 (Service Registry)服务注册中心,它是服务及其实例和元数据的数据库。服务实例在启动时注册到服务注册表,并在关闭时注销。服务和路由器的客户端查询服务注册表以查找服务的可用实例。服务注册中心可能会调用服务实例的健康检查 API 来验证它是否能够处理请求。服务元数据 (Service Metadata)服务元数据是指包括服务端点(endpoints)、服务标签、服务版本号、服务实例权重、路由规则、安全策略等描述服务的数据。服务提供方 (Service Provider)是指提供可复用和可调用服务的应用方。服务消费方 (Service Consumer)是指会发起对某个服务调用的应用方。
Nacos 注册中心架构和基本概念
服务注册:Nacos Client会通过发送REST请求的方式向Nacos Server注册自己的服务,提供自身的元数据,比如ip地址、端口等信息。Nacos Server接收到注册请求后,就会把这些元数据信息存储在一个双层的内存Map中。服务心跳:在服务注册后,Nacos Client会维护一个定时心跳来持续通知Nacos Server,说明服务一直处于可用状态,防止被剔除。默认5s发送一次心跳。服务同步:Nacos Server集群之间会互相同步服务实例,用来保证服务信息的一致性。 服务发现:服务消费者(Nacos Client)在调用服务提供者的服务时,会发送一个REST请求给Nacos Server,获取上面注册的服务清单,并且缓存在Nacos Client本地,同时会在Nacos Client本地开启一个定时任务定时拉取服务端最新的注册表信息更新到本地缓存服务健康检查:Nacos Server会开启一个定时任务用来检查注册服务实例的健康情况,对于超过15s没有收到客户端心跳的实例会将它的healthy属性置为false(客户端服务发现时不会发现),如果某个实例超过30秒没有收到心跳,直接剔除该实例(被剔除的实例如果恢复发送心跳则会重新注册)
Nacos注册中心核心功能
原因:Nacos2.x版本相比1.X新增了gRPC的通信方式,因此需要增加2个端口。新增端口是在配置的主端口(server.port)基础上,进行一定偏移量自动生成。端口 与主端口的偏移量 描述9848 1000 客户端gRPC请求服务端端口,用于客户端向服务端发起连接和请求9849 1001 服务端gRPC请求服务端端口,用于服务间同步等
1)环境准备
2.1)修改conf/application.properties的配置,使用外置数据源#使用外置mysql数据源 spring.datasource.platform=mysql ### Count of DB: db.num=1### Connect URL of DB: db.url.0=jdbc:mysql://192.168.65.204:3306/nacos?characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useUnicode=true&useSSL=false&serverTimezone=UTC db.user.0=rootdb.password.0=root
#多网卡选择#ip-address参数可以直接设置nacos的ip#该参数设置后,将会使用这个IP去cluster.conf里进行匹配,请确保这个IP的值在cluster.conf里是存在的nacos.inetutils.ip-address=192.168.65.206#use-only-site-local-interfaces参数可以让nacos使用局域网ip,这个在nacos部署的机器有多网卡时很有用,可以让nacos选择局域网网卡nacos.inetutils.use-only-site-local-interfaces=true#ignored-interfaces支持网卡数组,可以让nacos忽略多个网卡nacos.inetutils.ignored-interfaces[0]=eth0nacos.inetutils.ignored-interfaces[1]=eth1#preferred-networks参数可以让nacos优先选择匹配的ip,支持正则匹配和前缀匹配nacos.inetutils.preferred-networks[0]=30.5.124.
2)以192.168.65.204为例,进入nacos目录
3)mysql中创建nacos数据库sql脚本:https://github.com/alibaba/nacos/blob/2.1.0/distribution/conf/nacos-mysql.sql
#修改启动脚本vim bin\\startup.sh
4) 如果内存不够,可以调整jvm参数
5) 分别启动三个节点上的nacos以192.168.65.204为例,进入nacos目录,启动nacos#启动nacosbin/startup.sh
集群模式
Nacos注册中心(Nacos Server)环境搭建
Spring Cloud Alibaba版本选型版本说明:https://github.com/alibaba/spring-cloud-alibaba/wiki/%E7%89%88%E6%9C%AC%E8%AF%B4%E6%98%8E
<dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId></dependency><dependency> <groupId>com.alibaba.nacos</groupId> <artifactId>nacos-common</artifactId> <version>2.1.0</version></dependency>
1)引入依赖
server: port: 8020spring: application: name: mall-order #微服务名称 #配置nacos注册中心地址 cloud: nacos: discovery:
更多配置:https://github.com/alibaba/spring-cloud-alibaba/wiki/Nacos-discovery
2)配置nacos注册中心
微服务(Nacos Client)整合Nacos注册中心(Nacos Server)
Spring Cloud Alibaba Nacos快速开始
命名空间(Namespace)用于进行租户(用户)粒度的隔离,Namespace 的常用场景之一是不同环境的隔离,例如开发测试环境和生产环境的资源(如配置、服务)隔离等。
Namespace 隔离设计
group服务分组不同的服务可以归类到同一分组,group也可以起到服务隔离的作用。yml中可以通过spring.cloud.nacos.discovery.group参数配置
服务逻辑隔离
在定义上区分临时实例和持久化 实例的关键是健康检查的方式。临时实例使用客户端上报模式,而持久化实例使用服务端反向探测模式。临时实例需要能够自动摘除不健康实例,而且无需持久化存储实例。持久化实例使用服务端探测的健康检查方式,因为客户端不会上报心跳, 所以不能自动摘除下线的实例。
Nacos 1.x 中持久化及非 持久化的属性是作为实例的⼀个元数据进行存储和识别。Nacos 2.x 中继续沿用了持久化及非持久化的设定,但是有了⼀些调整。在 Nacos2.0 中将是否持久化的数据抽象至服务级别, 且不再允许⼀个服务同时存在持久化实例和非持久化实例,实例的持久化属性继承自服务的持久化属性。
# 持久化实例spring.cloud.nacos.discovery.ephemeral: false
临时实例和持久化实例
Nacos注册中心常见配置
什么是 Nacos
注册中心实战
架构图
服务注册:Nacos Client会通过发送REST请求的方式向Nacos Server注册自己的服务,提供自身的元数据,比如ip地址、端口等信息。Nacos Server接收到注册请求后,就会把这些元数据信息存储在一个双层的内存Map中。 服务心跳:在服务注册后,Nacos Client会维护一个定时心跳来持续通知Nacos Server,说明服务一直处于可用状态,防止被剔除。默认5s发送一次心跳。服务健康检查:Nacos Server会开启一个定时任务用来检查注册服务实例的健康情况,对于超过15s没有收到客户端心跳的实例会将它的healthy属性置为false(客户端服务发现时不会发现),如果某个实例超过30秒没有收到心跳,直接剔除该实例(被剔除的实例如果恢复发送心跳则会重新注册)服务发现:服务消费者(Nacos Client)在调用服务提供者的服务时,会发送一个REST请求给Nacos Server,获取上面注册的服务清单,并且缓存在Nacos Client本地,同时会在Nacos Client本地开启一个定时任务定时拉取服务端最新的注册表信息更新到本地缓存服务同步:Nacos Server集群之间会互相同步服务实例,用来保证服务信息的一致性。
Nacos核心功能点
Nacos核心功能源码架构图
举例
注册中心CAP架构剖析
Nacos源码剖析-集群数据一致性(持久化实例CP模式Raft协议实现)
1.4架构
Nacos 2.X 核心架构源码剖析
Nacos 2.X grpcServer启动源码剖析
Nacos 2.X grpcClient初始化源码剖析
2.x架构
Nacos整体原理
在微服务架构中,当系统从一个单体应用,被拆分成分布式系统上一个个服务节点后,配置文件也必须跟着迁移(分割),这样配置就分散了,不仅如此,分散中还包含着冗余。配置中心将配置从各应用中剥离出来,对配置进行统一管理,应用自身不需要自己去管理配置。
配置中心的服务流程如下:用户在配置中心更新配置信息。服务A和服务B及时得到配置更新通知,从配置中心获取配置。配置中心就是一种统一管理各种应用配置的基础服务组件。
Nacos 提供用于存储配置和其他元数据的 key/value 存储,为分布式系统中的外部化配置提供服务器端和客户端支持。使用 Spring Cloud Alibaba Nacos Config,您可以在 Nacos Server 集中管理你 Spring Cloud 应用的外部属性配置。
什么是Nacos配置中心
nacos server中新建nacos-config.properties
nacos server配置中心中准备配置数据
<dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
注意:必须使用 bootstrap.properties 配置文件来配置Nacos Server 地址spring.application.name=nacos-config# 配置中心地址spring.cloud.nacos.config.server-addr=127.0.0.1:8848# dataid 为 yaml 的文件扩展名配置方式spring.cloud.nacos.config.file-extension=yaml#profile粒度的配置 `${spring.application.name}-${profile}.${file-extension:properties}`
在 Nacos Spring Cloud 中,dataId 的完整格式如下:${prefix}-${spring.profiles.active}.${file-extension}prefix 默认为 spring.application.name 的值,也可以通过配置项 spring.cloud.nacos.config.prefix来配置。spring.profiles.active 即为当前环境对应的 profile,详情可以参考 Spring Boot文档。 注意:当 spring.profiles.active 为空时,对应的连接符 - 也将不存在,dataId 的拼接格式变成 ${prefix}.${file-extension}file-exetension 为配置内容的数据格式,可以通过配置项 spring.cloud.nacos.config.file-extension 来配置。
2)添加bootstrap.properties
微服务接入配置中心
Spring Cloud 整合Nacos配置中心快速开始
支持配置的动态更新
spring-cloud-starter-alibaba-nacos-config 在加载配置的时候,不仅仅加载了以 dataid 为 ${spring.application.name}.${file-extension:properties} 为前缀的基础配置,还加载了dataid为 ${spring.application.name}-${profile}.${file-extension:properties} 的基础配置。在日常开发中如果遇到多套环境下的不同配置,可以通过Spring 提供的 ${spring.profiles.active} 这个配置项来配置。spring.profiles.active=dev
支持profile粒度的配置
用于进行租户粒度的配置隔离。不同的命名空间下,可以存在相同的 Group 或 Data ID 的配置。Namespace 的常用场景之一是不同环境的配置的区分隔离,例如开发测试环境和生产环境的资源(如配置、服务)隔离等。在没有明确指定 ${spring.cloud.nacos.config.namespace} 配置的情况下, 默认使用的是 Nacos 上 Public 这个namespace。如果需要使用自定义的命名空间,可以通过以下配置来实现:spring.cloud.nacos.config.namespace=71bb9785-231f-4eca-b4dc-6be446e12ff8
支持自定义 namespace 的配置
Group是组织配置的维度之一。通过一个有意义的字符串(如 Buy 或 Trade )对配置集进行分组,从而区分 Data ID 相同的配置集。当您在 Nacos 上创建一个配置时,如果未填写配置分组的名称,则配置分组的名称默认采用 DEFAULT_GROUP 。配置分组的常见场景:不同的应用或组件使用了相同的配置类型,如 database_url 配置和 MQ_topic 配置。在没有明确指定 ${spring.cloud.nacos.config.group} 配置的情况下,默认是DEFAULT_GROUP 。如果需要自定义自己的 Group,可以通过以下配置来实现:spring.cloud.nacos.config.group=DEVELOP_GROUP
支持自定义 Group 的配置
Data ID 是组织划分配置的维度之一。Data ID 通常用于组织划分系统的配置集。一个系统或者应用可以包含多个配置集,每个配置集都可以被一个有意义的名称标识。Data ID 通常采用类 Java 包(如 com.taobao.tc.refund.log.level)的命名规则保证全局唯一性。此命名规则非强制。通过自定义扩展的 Data Id 配置,既可以解决多个应用间配置共享的问题,又可以支持一个应用有多个配置文件。
# 自定义 Data Id 的配置#不同工程的通用配置 支持共享的 DataIdspring.cloud.nacos.config.sharedConfigs[0].data-id= common.yamlspring.cloud.nacos.config.sharedConfigs[0].group=REFRESH_GROUPspring.cloud.nacos.config.sharedConfigs[0].refresh=true# config external configuration# 支持一个应用多个 DataId 的配置spring.cloud.nacos.config.extensionConfigs[0].data-id=ext-config-common01.propertiesspring.cloud.nacos.config.extensionConfigs[0].group=REFRESH_GROUPspring.cloud.nacos.config.extensionConfigs[0].refresh=truespring.cloud.nacos.config.extensionConfigs[1].data-id=ext-config-common02.propertiesspring.cloud.nacos.config.extensionConfigs[1].group=REFRESH_GROUP
支持自定义扩展的 Data Id 配置
Config相关配置
配置的优先级
@RestController@RefreshScopepublic class TestController { @Value(\"${common.age}\") private String age; @GetMapping(\"/common\") public String hello() { return age; }
@Value注解可以获取到配置中心的值,但是无法动态感知修改后的值,需要利用@RefreshScope注解
当利用@RefreshScope刷新配置后会导致定时任务失效@SpringBootApplication@EnableScheduling // 开启定时任务功能public class NacosConfigApplication {}@RestController@RefreshScope //动态感知修改后的值public class TestController { @Value(\"${common.age}\") String age; @Value(\"${common.name}\") String name; @GetMapping(\"/common\") public String hello() { return name+\
@RefreshScope 导致@Scheduled定时任务失效问题
@RefreshScope实现动态感知
@RestController@RefreshScope //动态感知修改后的值public class TestController implements ApplicationListener<RefreshScopeRefreshedEvent>{ @Value(\"${common.age}\") String age; @Value(\"${common.name}\") String name; @GetMapping(\"/common\") public String hello() { return name+\
实现Spring事件监听器,监听 RefreshScopeRefreshedEvent事件,监听方法中进行一次定时方法的调用
解决方案
配置中心
https://www.processon.com/view/link/62d678c31e08531cf8db16ef
配置中心核心接口ConfigService
获取配置的主要方法是 NacosConfigService 类的 getConfig 方法,通常情况下该方法直接从本地文件中取得配置的值,如果本地文件不存在或者内容为空,则再通过grpc从远端拉取配置,并保存到本地快照中。
获取配置
配置中心客户端会通过对配置项注册监听器达到在配置项变更的时候执行回调的功能。ConfigService#getConfigAndSignListenerConfigService#addListener
Nacos 可以通过以上方式注册监听器,它们内部的实现均是调用 ClientWorker 类的 addCacheDataIfAbsent。其中 CacheData 是一个维护配置项和其下注册的所有监听器的实例,所有的 CacheData 都保存在 ClientWorker 类中的原子 cacheMap 中,其内部的核心成员有:
注册监听器
2.1 nacos config client源码分析
服务端启动时就会依赖 DumpService 的 init 方法,从数据库中 load 配置存储在本地磁盘上,并将一些重要的元信息例如 MD5 值缓存在内存中。服务端会根据心跳文件中保存的最后一次心跳时间,来判断到底是从数据库 dump 全量配置数据还是部分增量配置数据(如果机器上次心跳间隔是 6h 以内的话)。
全量 dump 当然先清空磁盘缓存,然后根据主键 ID 每次捞取一千条配置刷进磁盘和内存。增量 dump 就是捞取最近六小时的新增配置(包括更新的和删除的),先按照这批数据刷新一遍内存和文件,再根据内存里所有的数据全量去比对一遍数据库,如果有改变的再同步一次,相比于全量 dump 的话会减少一定的数据库 IO 和磁盘 IO 次数。
配置dump
发布配置的代码位于 ConfigController#publishConfig中。集群部署,请求一开始也只会打到一台机器,这台机器将配置插入Mysql中进行持久化。服务端并不是针对每次配置查询都去访问 MySQL ,而是会依赖 dump 功能在本地文件中将配置缓存起来。因此当单台机器保存完毕配置之后,需要通知其他机器刷新内存和本地磁盘中的文件内容,因此它会发布一个名为 ConfigDataChangeEvent 的事件,这个事件会通过grpc调用通知所有集群节点(包括自身),触发本地文件和内存的刷新。
配置发布
nacos config server源码分析
Nacos配置中心源码分析
Nacos
Spring Cloud Ribbon是基于Netflix Ribbon 实现的一套客户端的负载均衡工具,Ribbon客户端组件提供一系列的完善的配置,如超时,重试等。通过Load Balancer获取到服务提供的所有机器实例,Ribbon会自动基于某种规则(轮询,随机)去调用这些服务。Ribbon也可以实现我们自己的负载均衡算法。
<!--添加ribbon的依赖--><dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-netflix-ribbon</artifactId>
1) 引入ribbon依赖
@Configurationpublic class RestConfig { @Bean @LoadBalanced //开启负载均衡 public RestTemplate restTemplate() { return new RestTemplate(); }
2) RestTemplate 添加@LoadBalanced注解,让RestTemplate在请求时拥有客户端负载均衡的能力
Spring Cloud Alibaba整合Ribbon快速开始
@Autowiredprivate RestTemplate restTemplate;@RequestMapping(value = \"/findOrderByUserId/{id}\")public R findOrderByUserId(@PathVariable(\"id\") Integer id) { //模拟ribbon实现 String url = getUri(\"mall-order\")+\"/order/findOrderByUserId/\
模拟ribbon实现
参考源码: LoadBalancerAutoConfiguration。@LoadBalanced使用了@Qualifier,spring中@Qualifier用于筛选限定注入Bean。
@LoadBalanced利用@Qualifier作为restTemplates注入的筛选条件,筛选出具有负载均衡标识的RestTemplate。
被@LoadBalanced注解的restTemplate会被定制,添加LoadBalancerInterceptor拦截器。注意: SmartInitializingSingleton是在所有的bean都实例化完成之后才会调用的,所以在bean的实例化期间使用@LoadBalanced修饰的restTemplate是不具备负载均衡作用的。
@Beanpublic RestTemplate restTemplate(LoadBalancerInterceptor loadBalancerInterceptor) { RestTemplate restTemplate = new RestTemplate(); //注入loadBalancerInterceptor拦截器 restTemplate.setInterceptors(Arrays.asList(loadBalancerInterceptor)); return restTemplate;}
如果不使用@LoadBalanced注解,也可以通过添加LoadBalancerInterceptor拦截器让restTemplate起到负载均衡器的作用。
@LoadBalanced 注解原理
Ribbon内核原理
参考: org.springframework.cloud.netflix.ribbon.RibbonClientConfigurationIClientConfig:Ribbon的客户端配置,默认采用DefaultClientConfigImpl实现。IRule:Ribbon的负载均衡策略,默认采用ZoneAvoidanceRule实现,该策略能够在多区域环境下选出最佳区域的实例进行访问。IPing:Ribbon的实例检查策略,默认采用DummyPing实现,该检查策略是一个特殊的实现,实际上它并不会检查实例是否可用,而是始终返回true,默认认为所有服务实例都是可用的。ServerList:服务实例清单的维护机制,默认采用ConfigurationBasedServerList实现。ServerListFilter:服务实例清单过滤机制,默认采ZonePreferenceServerListFilter,该策略能够优先过滤出与请求方处于同区域的服务实例。 ILoadBalancer:负载均衡器,默认采用ZoneAwareLoadBalancer实现,它具备了区域感知的能力。
Ribbon相关接口
RandomRule: 随机选择一个Server。RetryRule: 对选定的负载均衡策略机上重试机制,在一个配置时间段内当选择Server不成功,则一直尝试使用subRule的方式选择一个可用的server。RoundRobinRule: 轮询选择, 轮询index,选择index对应位置的Server。AvailabilityFilteringRule: 过滤掉一直连接失败的被标记为circuit tripped的后端Server,并过滤掉那些高并发的后端Server或者使用一个AvailabilityPredicate来包含过滤server的逻辑,其实就是检查status里记录的各个Server的运行状态。BestAvailableRule: 选择一个最小的并发请求的Server,逐个考察Server,如果Server被tripped了,则跳过。WeightedResponseTimeRule: 根据响应时间加权,响应时间越长,权重越小,被选中的可能性越低。ZoneAvoidanceRule: 默认的负载均衡策略,即复合判断Server所在区域的性能和Server的可用性选择Server,在没有区域的环境下,类似于轮询 NacosRule: 优先调用同一集群的实例,基于随机权重
@Configurationpublic class RibbonConfig { /** * 全局配置 * 指定负载均衡策略 * @return */ @Bean public IRule ribbonRule() { // 指定使用Nacos提供的负载均衡策略(优先调用同一集群的实例,基于随机权重) return new NacosRule(); }
全局配置:调用的微服务,一律使用指定的负载均衡策略
# 被调用的微服务名mall-order: ribbon: # 指定使用Nacos提供的负载均衡策略(优先调用同一集群的实例,基于随机&权重) NFLoadBalancerRuleClassName: com.alibaba.cloud.nacos.ribbon.NacosRule
局部配置:调用指定微服务时,使用对应的负载均衡策略
@Slf4jpublic class NacosRandomWithWeightRule extends AbstractLoadBalancerRule { @Autowired private NacosDiscoveryProperties nacosDiscoveryProperties; @Override public Server choose(Object key) { DynamicServerListLoadBalancer loadBalancer = (DynamicServerListLoadBalancer) getLoadBalancer(); String serviceName = loadBalancer.getName(); NamingService namingService = nacosDiscoveryProperties.namingServiceInstance(); try { //nacos基于权重的算法 Instance instance = namingService.selectOneHealthyInstance(serviceName); return new NacosServer(instance); } catch (NacosException e) { log.error(\"获取服务实例异常:{}\
1)实现基于Nacos权重的负载均衡策略
@Beanpublic IRule ribbonRule() { return new NacosRandomWithWeightRule();}
2.1)全局配置
# 被调用的微服务名mall-order: ribbon: # 自定义的负载均衡策略(基于随机&权重) NFLoadBalancerRuleClassName: com.tuling.mall.ribbondemo.rule.NacosRandomWithWeightRule
2.2)局部配置:
在进行服务调用的时候,如果网络情况不好,第一次调用会超时。Ribbon默认懒加载,意味着只有在发起调用的时候才会创建客户端。开启饥饿加载,解决第一次调用慢的问题ribbon: eager-load: enabled: true clients: mall-order参数说明:ribbon.eager-load.enabled:开启ribbon的饥饿加载模式ribbon.eager-load.clients:指定需要饥饿加载的服务名,也就是你需要调用的服务,如果有多个服务,则用逗号隔开
饥饿加载
2) 配置自定义的策略
自定义负载均衡策略通过实现 IRule 接口可以自定义负载策略,主要的选择服务逻辑在 choose 方法中。
修改默认负载均衡策略
Ribbon负载均衡策略
Ribbon扩展功能
什么是Ribbon
Spring官方提供了两种客户端都可以使用loadbalancer:RestTemplateRestTemplate是Spring提供的用于访问Rest服务的客户端,RestTemplate提供了多种便捷访问远程Http服务的方法,能够大大提高客户端的编写效率。默认情况下,RestTemplate默认依赖jdk的HTTP连接工具。WebClientWebClient是从Spring WebFlux 5.0版本开始提供的一个非阻塞的基于响应式编程的进行Http请求的客户端工具。它的响应式编程的基于Reactor的。WebClient中提供了标准Http请求方式对应的get、post、put、delete等方法,可以用来发起相应的请求。
<!-- LoadBalancer --><dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-loadbalancer</artifactId></dependency><!-- 提供了RestTemplate支持 --><dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId></dependency><!-- nacos服务注册与发现 移除ribbon支持--><dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> <exclusions> <exclusion> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-netflix-ribbon</artifactId> </exclusion> </exclusions></dependency>
注意: nacos-discovery中引入了ribbon,需要移除ribbon的包如果不移除,也可以在yml中配置不使用ribbon。默认情况下,如果同时拥有RibbonLoadBalancerClient和BlockingLoadBalancerClient,为了保持向后兼容性,将使用RibbonLoadBalancerClient。要覆盖它,可以设置spring.cloud.loadbalancer.ribbon.enabled属性为false。
spring: application: name: mall-user-loadbalancer-demo cloud: nacos: discovery: server-addr: 127.0.0.1:8848 # 不使用ribbon,使用loadbalancer loadbalancer: ribbon: enabled: false
@Configurationpublic class RestConfig { @Bean @LoadBalanced public RestTemplate restTemplate() { return new RestTemplate(); }}
2)使用@LoadBalanced注解修饰RestTemplate,开启客户端负载均衡功能
RestTemplate整合LoadBalancer
<!-- LoadBalancer --><dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-loadbalancer</artifactId></dependency><!-- webflux --><dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId></dependency><!-- nacos服务注册与发现 --><dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> <exclusions> <exclusion> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-netflix-ribbon</artifactId> </exclusion> </exclusions></dependency>
@Configurationpublic class WebClientConfig { @LoadBalanced @Bean WebClient.Builder webClientBuilder() { return WebClient.builder(); } @Bean WebClient webClient() { return webClientBuilder().build(); }}
2) 配置WebClient作为负载均衡器的client
@Autowiredprivate ReactorLoadBalancerExchangeFilterFunction lbFunction;@RequestMapping(value = \"/findOrderByUserId3/{id}\")public Mono<R> findOrderByUserIdWithWebFlux(@PathVariable(\"id\") Integer id) { String url = \"http://mall-order/order/findOrderByUserId/\"+id; //基于WebClient+webFlux Mono<R> result = WebClient.builder() .filter(lbFunction) .build() .get() .uri(url) .retrieve() .bodyToMono(R.class); return result;}
引入webFlux
3.2 WebClient整合LoadBalancer
什么是LoadBalancer
Feign是Netflix开发的声明式、模板化的HTTP客户端,Feign可帮助我们更加便捷、优雅地调用HTTP API。Feign可以做到使用 HTTP 请求远程服务时就像调用本地方法一样的体验,开发者完全感知不到这是远程方法,更感知不到这是个 HTTP 请求。它像 Dubbo 一样,consumer 直接调用接口方法调用 provider,而不需要通过常规的 Http Client 构造请求再解析返回数据。它解决了让开发者调用远程接口就跟调用本地方法一样,无需关注与远程的交互细节,更无需关注分布式环境开发。Spring Cloud openfeign对Feign进行了增强,使其支持Spring MVC注解,另外还整合了Ribbon和Eureka,从而使得Feign的使用更加方便。
@Bean@LoadBalancedpublic RestTemplate restTemplate() { return new RestTemplate();}//调用方式String url = \"http://mall-order/order/findOrderByUserId/\
Ribbon+RestTemplate进行微服务调用
@FeignClient(value = \"mall-order\
Feign进行微服务调用
Ribbon&Feign对比
Feign的设计架构
<!-- openfeign 远程调用 --><dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-openfeign</artifactId></dependency>
2)编写调用接口+@FeignClient注解
3)调用端在启动类上添加@EnableFeignClients注解
Spring Cloud Alibaba快速整合Feign
// 注意: 此处配置@Configuration注解就会全局生效,如果想指定对应微服务生效,就不能配置@Configuration@Configurationpublic class FeignConfig { /** * 日志级别 * * @return */ @Bean public Logger.Level feignLoggerLevel() { return Logger.Level.FULL; }}
通过源码可以看到日志等级有 4 种,分别是:NONE【性能最佳,适用于生产】:不记录任何日志(默认值)。BASIC【适用于生产环境追踪问题】:仅记录请求方法、URL、响应状态代码以及执行时间。HEADERS:记录BASIC级别的基础上,记录请求和响应的header。FULL【比较适用于开发及测试环境定位问题】:记录请求和响应的header、body和元数据。
1)定义一个配置类,指定日志级别
2) 局部配置,让调用的微服务生效,在@FeignClient 注解中指定使用的配置类
logging: level: com.tuling.mall.feigndemo.feign: debug
3) 在yml配置文件中配置 Client 的日志级别才能正常输出日志,格式是\"logging.level.feign接口包路径=debug\"logging:
对应属性配置类: org.springframework.cloud.openfeign.FeignClientProperties.FeignClientConfigurationfeign: client: config: mall-order: #对应微服务 loggerLevel: FULL
补充:局部配置可以在yml中配置
日志配置
Spring Cloud 在 Feign 的基础上做了扩展,可以让 Feign 支持 Spring MVC 的注解来调用。原生的 Feign 是不支持 Spring MVC 注解的,如果你想在 Spring Cloud 中使用原生的注解方式来定义客户端也是可以的,通过配置契约来改变这个配置,Spring Cloud 中默认的是 SpringMvcContract。
/** * 修改契约配置,支持Feign原生的注解 * @return */@Beanpublic Contract feignContract() { return new Contract.Default();}
注意:修改契约配置后,OrderFeignService 不再支持springmvc的注解,需要使用Feign原生的注解
1)修改契约配置,支持Feign原生的注解
2)OrderFeignService 中配置使用Feign原生的注解
feign: client: config: mall-order: #对应微服务 loggerLevel: FULL contract: feign.Contract.Default #指定Feign原生注解契约配置
3)补充,也可以通过yml配置契约
契约配置
通常我们调用的接口都是有权限控制的,很多时候可能认证的值是通过参数去传递的,还有就是通过请求头去传递认证信息,比如 Basic 认证方式。
@Configuration // 全局配置public class FeignConfig { @Bean public BasicAuthRequestInterceptor basicAuthRequestInterceptor() { return new BasicAuthRequestInterceptor(\"fox\
Feign 中我们可以直接配置 Basic 认证
每次 feign 发起http调用之前,会去执行拦截器中的逻辑。public interface RequestInterceptor { /** * Called for every request. Add data using methods on the supplied {@link RequestTemplate}. */ void apply(RequestTemplate template);}
使用场景统一添加 header 信息;对 body 中的信息做修改或替换;
扩展点: feign.RequestInterceptor
public class FeignAuthRequestInterceptor implements RequestInterceptor { @Override public void apply(RequestTemplate template) { // 业务逻辑 String access_token = UUID.randomUUID().toString(); template.header(\"Authorization\
自定义拦截器实现认证逻辑
补充:可以在yml中配置
通过拦截器实现参数传递
通过 Options 可以配置连接超时时间和读取超时时间,Options 的第一个参数是连接的超时时间(ms),默认值是 2s;第二个是请求处理的超时时间(ms),默认值是 5s。
全局配置
feign: client: config: mall-order: #对应微服务 # 连接超时时间,默认2s connectTimeout: 5000 # 请求处理超时时间,默认5s readTimeout: 10000
补充说明: Feign的底层用的是Ribbon,但超时时间以Feign配置为准
yml中配置
超时时间配置
Feign 中默认使用 JDK 原生的 URLConnection 发送 HTTP 请求,我们可以集成别的组件来替换掉 URLConnection,比如 Apache HttpClient,OkHttp。
Feign发起调用真正执行逻辑:feign.Client#execute (扩展点)
<!-- Apache HttpClient --><dependency> <groupId>org.apache.httpcomponents</groupId> <artifactId>httpclient</artifactId> <version>4.5.7</version></dependency><dependency> <groupId>io.github.openfeign</groupId> <artifactId>feign-httpclient</artifactId> <version>10.1.0</version></dependency>
引入依赖
然后修改yml配置,将 Feign 的 Apache HttpClient启用 :feign: #feign 使用 Apache HttpClient 可以忽略,默认开启 httpclient: enabled: true
关于配置可参考源码: org.springframework.cloud.openfeign.FeignAutoConfiguration测试:调用会进入feign.httpclient.ApacheHttpClient#execute
配置Apache HttpClient
引入依赖<dependency> <groupId>io.github.openfeign</groupId> <artifactId>feign-okhttp</artifactId></dependency>
然后修改yml配置,将 Feign 的 HttpClient 禁用,启用 OkHttp,配置如下:feign: #feign 使用 okhttp httpclient: enabled: false okhttp: enabled: true
关于配置可参考源码: org.springframework.cloud.openfeign.FeignAutoConfiguration测试:调用会进入feign.okhttp.OkHttpClient#execute
配置 OkHttp
客户端组件配置
注意:只有当 Feign 的 Http Client 不是 okhttp3 的时候,压缩才会生效,配置源码在FeignAcceptGzipEncodingAutoConfiguration核心代码就是 @ConditionalOnMissingBean(type=\"okhttp3.OkHttpClient\"),表示 Spring BeanFactory 中不包含指定的 bean 时条件匹配,也就是没有启用 okhttp3 时才会进行压缩配置。
开启压缩可以有效节约网络资源,提升接口性能,我们可以配置 GZIP 来压缩数据
GZIP 压缩配置
Feign 中提供了自定义的编码解码器设置,同时也提供了多种编码器的实现,比如 Gson、Jaxb、Jackson。我们可以用不同的编码解码器来处理数据的传输。如果你想传输 XML 格式的数据,可以自定义 XML 编码解码器来实现获取使用官方提供的 Jaxb。
扩展点:Encoder & Decoder
@Beanpublic Decoder decoder() { return new JacksonDecoder();}@Beanpublic Encoder encoder() { return new JacksonEncoder();}
Java配置方式配置编码解码器只需要在 Feign 的配置类中注册 Decoder 和 Encoder 这两个类即可:
yml配置方式feign: client: config: mall-order: #对应微服务 # 配置编解码器 encoder: feign.jackson.JacksonEncoder decoder: feign.jackson.JacksonDecoder
编码器解码器配置
Spring Cloud Feign扩展
什么是Feign
实战
Ribbon&Loadbalance
在不做任何处理的情况下,服务提供者不可用会导致消费者请求线程强制等待,而造成系统资源耗尽。加入超时机制,一旦超时,就释放资源。由于释放资源速度较快,一定程度上可以抑制资源耗尽的问题。
超时机制
限制请求核心服务提供者的流量,使大流量拦截在核心服务之外,这样可以更好的保证核心服务提供者不出问题,对于一些出问题的服务可以限制流量访问,只分配固定线程资源访问,这样能使整体的资源不至于被出问题的服务耗尽,进而整个系统雪崩。那么服务之间怎么限流,怎么资源隔离?例如可以通过线程池+队列的方式,通过信号量的方式。
服务限流(资源隔离)
服务熔断
所谓降级,就是当某个服务熔断之后,服务将不再被调用,此时客户端可以自己准备一个本地的fallback(回退)回调,返回一个缺省值。 例如:(备用接口/缓存/mock数据) 。这样做,虽然服务水平下降,但好歹可用,比直接挂掉要强,当然这也要看适合的业务场景。
服务降级有服务熔断,必然要有服务降级。
分布式系统遇到的问题
随着微服务的流行,服务和服务之间的稳定性变得越来越重要。Sentinel 是面向分布式、多语言异构化服务架构的流量治理组件,主要以流量为切入点,从流量路由、流量控制、流量整形、熔断降级、系统自适应过载保护、热点流量防护等多个维度来帮助开发者保障微服务的稳定性。源码地址:https://github.com/alibaba/Sentinel
Sentinel具有以下特征:丰富的应用场景: Sentinel 承接了阿里巴巴近 10 年的双十一大促流量的核心场景,例如秒杀(即突发流量控制在系统容量可以承受的范围)、消息削峰填谷、实时熔断下游不可用应用等。完备的实时监控: Sentinel 同时提供实时的监控功能。您可以在控制台中看到接入应用的单台机器秒级数据,甚至 500 台以下规模的集群的汇总运行情况。广泛的开源生态: Sentinel 提供开箱即用的与其它开源框架/库的整合模块,例如与 Spring Cloud、Dubbo、gRPC 的整合。您只需要引入相应的依赖并进行简单的配置即可快速地接入 Sentinel。完善的 SPI 扩展点: Sentinel 提供简单易用、完善的 SPI 扩展点。您可以通过实现扩展点,快速的定制逻辑。例如定制规则管理、适配数据源等。阿里云提供了 企业级的 Sentinel 服务,应用高可用服务 AHAS
Sentinel 是什么
Hystrix 的关注点在于以 隔离 和 熔断 为主的容错机制,超时或被熔断的调用将会快速失败,并可以提供 fallback 机制。而 Sentinel 的侧重点在于:
而 Sentinel 的侧重点在于:多样化的流量控制熔断降级系统负载保护实时监控和控制台
Sentinel和Hystrix对比https://github.com/alibaba/Sentinel/wiki/Sentinel-%E4%B8%8E-Hystrix-%E7%9A%84%E5%AF%B9%E6%AF%94
流量控制在网络传输中是一个常用的概念,它用于调整网络包的发送数据。然而,从系统稳定性角度考虑,在处理请求的速度上,也有非常多的讲究。任意时间到来的请求往往是随机不可控的,而系统的处理能力是有限的。我们需要根据系统的处理能力对流量进行控制。Sentinel 作为一个调配器,可以根据需要把随机的请求调整成合适的形状,如下图所示:
流量控制有以下几个角度:资源的调用关系,例如资源的调用链路,资源和资源之间的关系;运行指标,例如 QPS、线程池、系统负载等;控制的效果,例如直接限流、冷启动、排队等。Sentinel 的设计理念是让您自由选择控制的角度,并进行灵活组合,从而达到想要的效果。
流量控制
Sentinel 和 Hystrix 的原则是一致的: 当调用链路中某个资源出现不稳定,例如,表现为 timeout,异常比例升高的时候,则对这个资源的调用进行限制,并让请求快速失败,避免影响到其它的资源,最终产生雪崩的效果。
在限制的手段上,Sentinel 和 Hystrix 采取了完全不一样的方法。Hystrix 通过线程池的方式,来对依赖(在我们的概念中对应资源)进行了隔离。这样做的好处是资源和资源之间做到了最彻底的隔离。缺点是除了增加了线程切换的成本,还需要预先给各个资源做线程池大小的分配。
Sentinel 对这个问题采取了两种手段:通过并发线程数进行限制和资源池隔离的方法不同,Sentinel 通过限制资源并发线程的数量,来减少不稳定资源对其它资源的影响。这样不但没有线程切换的损耗,也不需要您预先分配线程池的大小。当某个资源出现不稳定的情况下,例如响应时间变长,对资源的直接影响就是会造成线程数的逐步堆积。当线程数在特定资源上堆积到一定的数量之后,对该资源的新请求就会被拒绝。堆积的线程完成任务后才开始继续接收请求。通过响应时间对资源进行降级除了对并发线程数进行控制以外,Sentinel 还可以通过响应时间来快速降级不稳定的资源。当依赖的资源出现响应时间过长后,所有对该资源的访问都会被直接拒绝,直到过了指定的时间窗口之后才重新恢复。
熔断降级
Sentinel 同时提供系统维度的自适应保护能力。防止雪崩,是系统防护中重要的一环。当系统负载较高的时候,如果还持续让请求进入,可能会导致系统崩溃,无法响应。在集群环境下,网络负载均衡会把本应这台机器承载的流量转发到其它的机器上去。如果这个时候其它的机器也处在一个边缘状态的时候,这个增加的流量就会导致这台机器也崩溃,最后导致整个集群不可用。针对这个情况,Sentinel 提供了对应的保护机制,让系统的入口流量和系统的负载达到一个平衡,保证系统在能力范围之内处理最多的请求。
系统负载保护
Sentinel 功能和设计理念
https://github.com/alibaba/Sentinel/wiki/Sentinel%E5%B7%A5%E4%BD%9C%E4%B8%BB%E6%B5%81%E7%A8%8B在 Sentinel 里面,所有的资源都对应一个资源名称(resourceName),每次资源调用都会创建一个 Entry 对象。Entry 可以通过对主流框架的适配自动创建,也可以通过注解的方式或调用 SphU API 显式创建。Entry 创建的时候,同时也会创建一系列功能插槽(slot chain),这些插槽有不同的职责,例如:
Sentinel 将 ProcessorSlot 作为 SPI 接口进行扩展(1.7.2 版本以前 SlotChainBuilder 作为 SPI),使得 Slot Chain 具备了扩展的能力。您可以自行加入自定义的 slot 并编排 slot 间的顺序,从而可以给 Sentinel 添加自定义的功2能。
Sentinel工作主流程
Sentinel 工作原理
Entry entry = null;// 务必保证 finally 会被执行try { // 资源名可使用任意有业务语义的字符串 开启资源的保护 entry = SphU.entry(\"自定义资源名\
在官方文档中,定义的Sentinel进行资源保护的几个步骤:定义资源定义规则检验规则是否生效
<dependency> <groupId>com.alibaba.csp</groupId> <artifactId>sentinel-core</artifactId> <version>1.8.4</version></dependency>
@RestController@Slf4jpublic class HelloController { private static final String RESOURCE_NAME = \"HelloWorld\"; @RequestMapping(value = \"/hello\") public String hello() { try (Entry entry = SphU.entry(RESOURCE_NAME)) { // 被保护的逻辑 log.info(\"hello world\"); return \"hello world\"; } catch (BlockException ex) { // 处理被流控的逻辑 log.info(\"blocked!\"); return \"被流控了\"; } } /** * 定义流控规则 */ @PostConstruct private static void initFlowRules(){ List<FlowRule> rules = new ArrayList<>(); FlowRule rule = new FlowRule(); //设置受保护的资源 rule.setResource(RESOURCE_NAME); // 设置流控规则 QPS rule.setGrade(RuleConstant.FLOW_GRADE_QPS); // 设置受保护的资源阈值 // Set limit QPS to 20. rule.setCount(1); rules.add(rule); // 加载配置好的规则 FlowRuleManager.loadRules(rules); }}
编写测试逻辑
缺点:业务侵入性很强,需要在controller中写入非业务代码.配置不灵活 若需要添加新的受保护资源 需要手动添加 init方法来添加流控规则
基于API实现
@SentinelResource 注解用来标识资源是否被限流、降级。
blockHandler: 定义当资源内部发生了BlockException应该进入的方法(捕获的是Sentinel定义的异常)fallback: 定义的是资源内部发生了Throwable应该进入的方法exceptionsToIgnore:配置fallback可以忽略的异常
源码入口:com.alibaba.csp.sentinel.annotation.aspectj.SentinelResourceAspect
<dependency> <groupId>com.alibaba.csp</groupId> <artifactId>sentinel-annotation-aspectj</artifactId> <version>1.8.4</version></dependency>
1.引入依赖
@Configurationpublic class SentinelAspectConfiguration { @Bean public SentinelResourceAspect sentinelResourceAspect() { return new SentinelResourceAspect(); }}
2.配置切面支持
@SentinelResource(value = \"hello world\
3.UserController中编写测试逻辑,添加@SentinelResource,并配置blockHandler和fallback
4.编写ExceptionUtil,注意如果指定了class,方法必须是static方法
客户端需要引入 Transport 模块来与 Sentinel 控制台进行通信。<dependency> <groupId>com.alibaba.csp</groupId> <artifactId>sentinel-transport-simple-http</artifactId> <version>1.8.4</version></dependency>
5.流控规则设置可以通过Sentinel dashboard配置
下载控制台 jar 包并在本地启动:可以参见 此处文档#启动控制台命令java -jar sentinel-dashboard-1.8.4.jar
用户可以通过如下参数进行配置:-Dsentinel.dashboard.auth.username=sentinel 用于指定控制台的登录用户名为 sentinel;-Dsentinel.dashboard.auth.password=123456 用于指定控制台的登录密码为 123456;如果省略这两个参数,默认用户和密码均为 sentinel;-Dserver.servlet.session.timeout=7200 用于指定 Spring Boot 服务端 session 的过期时间,如 7200 表示 7200 秒;60m 表示 60 分钟,默认为 30 分钟;
6. 启动 Sentinel 控制台
@SentinelResource注解实现
Sentinel资源保护的方式
<dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId></dependency><dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId></dependency>
server: port: 8800spring: application: name: mall-user-sentinel-demo cloud: nacos: discovery: server-addr: 127.0.0.1:8848 sentinel: transport: # 添加sentinel的控制台地址 dashboard: 127.0.0.1:8080 # 指定应用与Sentinel控制台交互的端口,应用本地会起一个该端口占用的HttpServer # port: 8719 #暴露actuator端点 management: endpoints: web: exposure: include: '*'
2.添加yml配置,为微服务设置sentinel控制台地址
资源名: 接口的API 针对来源: 默认是default,当多个微服务都调用这个资源时,可以配置微服务名来对指定的微服务设置阈值阈值类型: 分为QPS和线程数 假设阈值为10QPS类型: 只得是每秒访问接口的次数>10就进行限流线程数: 为接受请求该资源分配的线程数>10就进行限流
3.在sentinel控制台中设置流控规则
Sentinel控制台与微服务端之间,实现了一套服务发现机制,集成了Sentinel的微服务都会将元数据传递给Sentinel控制台,架构图如下所示:
微服务和Sentinel Dashboard通信原理
2.4 Spring Cloud Alibaba整合Sentinel
Sentinel快速开始
Sentinel: 分布式系统的流量防卫兵
Sentinel 提供一个轻量级的开源控制台,它提供机器发现以及健康情况管理、监控(单机和集群),规则管理和推送的功能。Sentinel 控制台包含如下功能:查看机器列表以及健康情况:收集 Sentinel 客户端发送的心跳包,用于判断机器是否在线。监控 (单机和集群聚合):通过 Sentinel 客户端暴露的监控 API,定期拉取并且聚合应用监控信息,最终可以实现秒级的实时监控。规则管理和推送:统一管理推送规则。鉴权:生产环境中鉴权非常重要。这里每个开发者需要根据自己的实际情况进行定制。阿里云提供了 企业级的 Sentinel 控制台,应用高可用服务 AHAS
监控接口的通过的QPS和拒绝的QPS 。同一个服务下的所有机器的簇点信息会被汇总,并且秒级地展示在\"实时监控\"下。注意: 实时监控仅存储 5 分钟以内的数据,如果需要持久化,需要通过调用实时监控接口来定制。
1.1 实时监控
用来显示微服务的所监控的API。簇点链路(单机调用链路)页面实时的去拉取指定客户端资源的运行情况。它一共提供两种展示模式:一种用树状结构展示资源的调用链路,另外一种则不区分调用链路展示资源的运行情况。
注意: 簇点监控是内存态的信息,它仅展示启动后调用过的资源。
1.2 簇点链路
流量控制(flow control),其原理是监控应用流量的 QPS 或并发线程数等指标,当达到指定的阈值时对流量进行控制,以避免被瞬时的流量高峰冲垮,从而保障应用的高可用性。 同一个资源可以创建多条限流规则。FlowSlot 会对该资源的所有限流规则依次遍历,直到有规则触发限流或者所有规则遍历完毕。一条限流规则主要由下面几个因素组成,我们可以组合这些元素来实现不同的限流效果。
Field 说明 默认值resource 资源名,资源名是限流规则的作用对象count 限流阈值grade 限流阈值类型,QPS 模式(1)或并发线程数模式(0) QPS 模式limitApp 流控针对的调用来源 default,代表不区分调用来源strategy 调用关系限流策略:直接、链路、关联 根据资源本身(直接)controlBehavior 流控效果(直接拒绝/WarmUp/匀速+排队等待),不支持按调用关系限流 直接拒绝clusterMode 是否集群限流否
流量控制主要有两种统计类型,一种是统计并发线程数,另外一种则是统计 QPS。类型由 FlowRule 的 grade 字段来定义。其中,0 代表根据并发数量来限流,1 代表根据 QPS 来进行流量控制。 QPS(Query Per Second):每秒请求数,就是说服务器在一秒的时间内处理了多少个请求。
QPS进入簇点链路选择具体的访问的API,然后点击流控按钮
springwebmvc接口资源限流入口在HandlerInterceptor的实现类AbstractSentinelInterceptor的preHandle方法中,对异常的处理是BlockExceptionHandler的实现类
自定义BlockExceptionHandler 的实现类统一处理BlockException
BlockException异常统一处理
并发线程数控制用于保护业务线程池不被慢调用耗尽。例如,当应用所依赖的下游应用由于某种原因导致服务不稳定、响应延迟增加,对于调用者来说,意味着吞吐量下降和更多的线程数占用,极端情况下甚至导致线程池耗尽。为应对太多线程占用的情况,业内有使用隔离的方案,比如通过不同业务逻辑使用不同线程池来隔离业务自身之间的资源争抢(线程池隔离)。这种隔离方案虽然隔离性比较好,但是代价就是线程数目太多,线程上下文切换的 overhead 比较大,特别是对低延时的调用有比较大的影响。Sentinel 并发控制不负责创建和管理线程池,而是简单统计当前请求上下文的线程数目(正在执行的调用数目),如果超出阈值,新的请求会被立即拒绝,效果类似于信号量隔离。并发数控制通常在调用端进行配置。
并发线程数
限流阈值类型
基于调用关系的流量控制。调用关系包括调用方、被调用方;一个方法可能会调用其它方法,形成一个调用链路的层次关系。
资源调用达到设置的阈值后直接被流控抛出异常
直接
当两个资源之间具有资源争抢或者依赖关系的时候,这两个资源便具有了关联。比如对数据库同一个字段的读操作和写操作存在争抢,读的速度过高会影响写得速度,写的速度过高会影响读的速度。如果放任读写操作争抢资源,则争抢本身带来的开销会降低整体的吞吐量。可使用关联限流来避免具有关联关系的资源之间过度的争抢,举例来说,read_db 和 write_db 这两个资源分别代表数据库读写,我们可以给 read_db 设置限流规则来达到写优先的目的:设置 strategy 为 RuleConstant.STRATEGY_RELATE 同时设置 refResource 为 write_db。这样当写库操作过于频繁时,读数据的请求会被限流。
关联
流控模式
根据调用链路入口限流。 NodeSelectorSlot 中记录了资源之间的调用链路,这些资源通过调用关系,相互之间构成一棵调用树。这棵树的根节点是一个名字为 machine-root 的虚拟节点,调用链的入口都是这个虚节点的子节点。
一棵典型的调用树如下图所示: machine-root / \\ / \\ Entrance1 Entrance2 / \\ / \\ DefaultNode(nodeA) DefaultNode(nodeA)
上图中来自入口 Entrance1 和 Entrance2 的请求都调用到了资源 NodeA,Sentinel 允许只根据某个入口的统计信息对资源限流。
测试会发现链路规则不生效注意,高版本此功能直接使用不生效,如何解决?从1.6.3版本开始,Sentinel Web filter默认收敛所有URL的入口context,导致链路限流不生效。从1.7.0版本开始,官方在CommonFilter引入了WEB_CONTEXT_UNIFY参数,用于控制是否收敛context,将其配置为false即可根据不同的URL进行链路限流。
1.8.4中 需要在yml中配置spring.cloud.sentinel.web-context-unify属性为false # 将其配置为 false 即可根据不同的 URL 进行链路限流spring.cloud.sentinel.web-context-unify: false
再次测试链路规则,链路规则生效,但是出现异常控制台打印FlowException异常
解决方案: 在@SentinelResource注解中指定blockHandler处理BlockException)// UserServiceImpl.java@Override@SentinelResource(value = \"getUser\
如果此过程没有处理FlowException,AOP就会对异常进行处理,核心代码在CglibAopProxy.CglibMethodInvocation#proceed中,抛出UndeclaredThrowableException异常,此异常属于RuntimeException,所以不会被BlockException异常机制处理处理。
链路
当 QPS 超过某个阈值的时候,则采取措施进行流量控制。流量控制的效果包括以下几种:快速失败(直接拒绝)、Warm Up(预热)、匀速排队(排队等待)。
快速失败:达到阈值后,新的请求会被立即拒绝并抛出FlowException异常。是默认的处理方式。warm up:预热模式,对超出阈值的请求同样是拒绝并抛出异常。但这种模式阈值会动态变化,从一个较小值逐渐增加到最大阈值。排队等待:让所有的请求按照先后次序排队执行,两个请求的间隔不能小于指定时长
对应 FlowRule 中的 controlBehavior 字段。
(RuleConstant.CONTROL_BEHAVIOR_DEFAULT)方式是默认的流量控制方式,当QPS超过任意规则的阈值后,新的请求就会被立即拒绝,拒绝方式为抛出FlowException。这种方式适用于对系统处理能力确切已知的情况下,比如通过压测确定了系统的准确水位时。
快速失败
Warm Up(RuleConstant.CONTROL_BEHAVIOR_WARM_UP)方式,即预热/冷启动方式。当系统长期处于低水位的情况下,当流量突然增加时,直接把系统拉升到高水位可能瞬间把系统压垮。通过\"冷启动\",让通过的流量缓慢增加,在一定时间内逐渐增加到阈值上限,给冷系统一个预热的时间,避免冷系统被压垮。
冷加载因子: codeFactor 默认是3,即请求 QPS 从 threshold / 3 开始,经预热时长逐渐升至设定的 QPS 阈值。 通常冷启动的过程系统允许通过的 QPS 曲线如下图所示
编辑流控规则
查看实时监控,可以看到通过QPS存在缓慢增加的过程
测试用例@RequestMapping(\"/test\")public String test() { try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } return \"========test()========\";}
Warm Up
匀速排队(`RuleConstant.CONTROL_BEHAVIOR_RATE_LIMITER`)方式会严格控制请求通过的间隔时间,也即是让请求以均匀的速度通过,对应的是漏桶算法。
该方式的作用如下图所示:
这种方式主要用于处理间隔性突发的流量,例如消息队列。想象一下这样的场景,在某一秒有大量的请求到来,而接下来的几秒则处于空闲状态,我们希望系统能够在接下来的空闲期间逐渐处理这些请求,而不是在第一秒直接拒绝多余的请求。 注意:匀速排队模式暂时不支持 QPS > 1000 的场景。
匀速排队
流控效果
1.3 流控规则
除了流量控制以外,对调用链路中不稳定的资源进行熔断降级也是保障高可用的重要措施之一。我们需要对不稳定的弱依赖服务调用进行熔断降级,暂时切断不稳定调用,避免局部不稳定因素导致整体的雪崩。熔断降级作为保护自身的手段,通常在客户端(调用端)进行配置。
熔断降级规则(DegradeRule)包含下面几个重要的属性:Field 说明 默认值resource 资源名,即规则的作用对象grade 熔断策略,支持慢调用比例/异常比例/异常数策略慢调用比例count 慢调用比例模式下为慢调用临界 RT(超出该值计为慢调用);异常比例/异常数模式下为对应的阈值timeWindow 熔断时长,单位为 sminRequestAmount 熔断触发的最小请求数,请求数小于该值时即使异常比率超出阈值也不会熔断(1.7.0 引入)5statIntervalMs统计时长(单位为 ms),如 60*1000 代表分钟级(1.8.0 引入)1000 msslowRatioThreshold慢调用比例阈值,仅慢调用比例模式有效(1.8.0 引入)
熔断降级规则说明
慢调用比例 (SLOW_REQUEST_RATIO):选择以慢调用比例作为阈值,需要设置允许的慢调用 RT(即最大的响应时间),请求的响应时间大于该值则统计为慢调用。当单位统计时长(statIntervalMs)内请求数目大于设置的最小请求数目,并且慢调用的比例大于阈值,则接下来的熔断时长内请求会自动被熔断。经过熔断时长后熔断器会进入探测恢复状态(HALF-OPEN 状态),若接下来的一个请求响应时间小于设置的慢调用 RT 则结束熔断,若大于设置的慢调用 RT 则会再次被熔断。
熔断策略之慢调用比例
配置降级规则
测试用例@RequestMapping(\"/test2\")public String test2() { atomicInteger.getAndIncrement(); if (atomicInteger.get() % 2 == 0){ //模拟异常和异常比率 int i = 1/0; } return \"========test2()========\";}
熔断策略之异常比例
异常数 (ERROR_COUNT):当单位统计时长内的异常数目超过阈值之后会自动进行熔断。经过熔断时长后熔断器会进入探测恢复状态(HALF-OPEN 状态),若接下来的一个请求成功完成(没有错误)则结束熔断,否则会再次被熔断。注意:异常降级仅针对业务异常,对 Sentinel 限流降级本身的异常(BlockException)不生效。
熔断策略之异常数
1.4 熔断降级规则
何为热点?热点即经常访问的数据。很多时候我们希望统计某个热点数据中访问频次最高的 Top K 数据,并对其访问进行限制。比如:商品 ID 为参数,统计一段时间内最常购买的商品 ID 并进行限制用户 ID 为参数,针对一段时间内频繁访问的用户 ID 进行限制热点参数限流会统计传入参数中的热点参数,并根据配置的限流阈值与模式,对包含热点参数的资源调用进行限流。热点参数限流可以看做是一种特殊的流量控制,仅对包含热点参数的资源调用生效。
注意:热点规则需要使用@SentinelResource(\"resourceName\")注解,否则不生效参数必须是7种基本数据类型才会生效
测试用例@RequestMapping(\"/info/{id}\")@SentinelResource(value = \"userinfo\
配置热点参数规则注意: 资源名必须是@SentinelResource(value=\"资源名\")中 配置的资源名,热点规则依赖于注解
1.5 热点规则
Sentinel 做系统自适应保护的目的:保证系统不被拖垮在系统稳定的前提下,保持系统的吞吐量系统保护规则是从应用级别的入口流量进行控制,从单台机器的总体 Load、RT、入口 QPS 和线程数四个维度监控应用数据,让系统尽可能跑在最大吞吐量的同时保证系统整体的稳定性。系统保护规则是应用整体维度的,而不是资源维度的,并且仅对入口流量生效。入口流量指的是进入应用的流量(EntryType.IN),比如 Web 服务或 Dubbo 服务端接收的请求,都属于入口流量。
Load(仅对 Linux/Unix-like 机器生效):当系统 load1 超过阈值,且系统当前的并发线程数超过系统容量时才会触发系统保护。系统容量由系统的 maxQps * minRt 计算得出。设定参考值一般是 CPU cores * 2.5。CPU usage(1.5.0+ 版本):当系统 CPU 使用率超过阈值即触发系统保护(取值范围 0.0-1.0)。RT:当单台机器上所有入口流量的平均 RT 达到阈值即触发系统保护,单位是毫秒。线程数:当单台机器上所有入口流量的并发线程数达到阈值即触发系统保护。入口 QPS:当单台机器上所有入口流量的 QPS 达到阈值即触发系统保护。
系统规则阈值类型
很多时候,我们需要根据调用来源来判断该次请求是否允许放行,这时候可以使用 Sentinel 的来源访问控制(黑白名单控制)的功能。来源访问控制根据资源的请求来源(origin)限制资源是否通过,若配置白名单则只有请求来源位于白名单内时才可通过;若配置黑名单则请求来源位于黑名单时不通过,其余的请求通过。
import com.alibaba.csp.sentinel.adapter.spring.webmvc.callback.RequestOriginParser;import org.springframework.stereotype.Component;import javax.servlet.http.HttpServletRequest;/** * @author Fox */@Componentpublic class MyRequestOriginParser implements RequestOriginParser { /** * 通过request获取来源标识,交给授权规则进行匹配 * @param request * @return */ @Override public String parseOrigin(HttpServletRequest request) { // 标识字段名称可以自定义 String origin = request.getParameter(\"serviceName\");// if (StringUtil.isBlank(origin)){// throw new IllegalArgumentException(\"serviceName参数未指定\");// } return origin; }}
第一步:实现com.alibaba.csp.sentinel.adapter.spring.webmvc.callback.RequestOriginParser接口,在parseOrigin方法中区分来源,并交给spring管理注意:如果引入CommonFilter,此处会多出一个
授权控制规则——来源访问控制(黑白名单)
1.6 系统规则——系统自适应保护
为什么要使用集群流控呢?假设我们希望给某个用户限制调用某个 API 的总 QPS 为 50,但机器数可能很多(比如有 100 台)。这时候我们很自然地就想到,找一个 server 来专门来统计总的调用量,其它的实例都与这台 server 通信来判断是否可以调用。这就是最基础的集群流控的方式。另外集群流控还可以解决流量不均匀导致总体限流效果不佳的问题。假设集群中有 10 台机器,我们给每台机器设置单机限流阈值为 10 QPS,理想情况下整个集群的限流阈值就为 100 QPS。不过实际情况下流量到每台机器可能会不均匀,会导致总量没有到的情况下某些机器就开始限流。因此仅靠单机维度去限制的话会无法精确地限制总体流量。而集群流控可以精确地控制整个集群的调用总量,结合单机限流兜底,可以更好地发挥流量控制的效果。https://github.com/alibaba/Sentinel/wiki/%E9%9B%86%E7%BE%A4%E6%B5%81%E6%8E%A7
集群流控中共有两种身份:Token Client:集群流控客户端,用于向所属 Token Server 通信请求 token。集群限流服务端会返回给客户端结果,决定是否限流。Token Server:即集群流控服务端,处理来自 Token Client 的请求,根据配置的集群规则判断是否应该发放 token(是否允许通过)。
Sentinel 集群流控支持限流规则和热点规则两种规则,并支持两种形式的阈值计算方式:集群总体模式:即限制整个集群内的某个资源的总体 qps 不超过此阈值。单机均摊模式:单机均摊模式下配置的阈值等同于单机能够承受的限额,token server 会根据连接数来计算总的阈值(比如独立模式下有 3 个 client 连接到了 token server,然后配的单机均摊阈值为 10,则计算出的集群总量就为 30),按照计算出的总的阈值来进行限制。这种方式根据当前的连接数实时计算总的阈值,对于机器经常进行变更的环境非常适合。
独立模式(Alone),即作为独立的 token server 进程启动,独立部署,隔离性好,但是需要额外的部署操作。独立模式适合作为 Global Rate Limiter 给集群提供流控服务。
嵌入模式(Embedded),即作为内置的 token server 与服务在同一进程中启动。在此模式下,集群中各个实例都是对等的,token server 和 client 可以随时进行转变,因此无需单独部署,灵活性比较好。但是隔离性不佳,需要限制 token server 的总 QPS,防止影响应用本身。嵌入模式适合某个应用集群内部的流控。
启动方式
云上版本 AHAS Sentinel 提供开箱即用的全自动托管集群流控能力,无需手动指定/分配 token server 以及管理连接状态,同时支持分钟小时级别流控、大流量低延时场景流控场景,同时支持 Istio/Envoy 场景的 Mesh 流控能力。
1.7 集群规则
Sentinel控制台介绍
Sentinel限流、熔断降级源码架构图
计数器法是限流算法里最简单也是最容易实现的一种算法。比如我们规定,对于A接口来说,我们1分钟的访问次数不能超过100个。那么我们可以这么做:在一开始的时候,我们可以设置一个计数器counter,每当一个请求过来的时候,counter就加1,如果counter的值大于100并且该请求与第一个 请求的间隔时间还在1分钟之内,那么说明请求数过多;如果该请求与第一个请求的间隔时间大于1分钟,且counter的值还在限流范围内,那么就重置 counter。
具体算法的伪代码:/** * 最简单的计数器限流算法 */public class Counter { public long timeStamp = System.currentTimeMillis(); // 当前时间 public int reqCount = 0; // 初始化计数器 public final int limit = 100; // 时间窗口内最大请求数 public final long interval = 1000 * 60; // 时间窗口ms public boolean limit() { long now = System.currentTimeMillis(); if (now < timeStamp + interval) { // 在时间窗口内 reqCount++; // 判断当前时间窗口内是否超过最大请求控制数 return reqCount <= limit; } else { timeStamp = now; // 超时后重置 reqCount = 1; return true; } }}
计数器法
滑动时间窗口,又称rolling window。为了解决计数器法统计精度太低的问题,引入了滑动窗口算法。下面这张图,很好地解释了滑动窗口算法:
在上图中,整个红色的矩形框表示一个时间窗口,在我们的例子中,一个时间窗口就是一分钟。然后我们将时间窗口进行划分,比如图中,我们就将滑动窗口划成了6格,所以每格代表的是10秒钟。每过10秒钟,我们的时间窗口就会往右滑动一格。每一个格子都有自己独立的计数器counter,比如当一个请求 在0:35秒的时候到达,那么0:30~0:39对应的counter就会加1。计数器算法其实就是滑动窗口算法。只是它没有对时间窗口做进一步地划分,所以只有1格。由此可见,当滑动窗口的格子划分的越多,那么滑动窗口的滚动就越平滑,限流的统计就会越精确。
具体算法的伪代码:/** * 滑动时间窗口限流实现 * 假设某个服务最多只能每秒钟处理100个请求,我们可以设置一个1秒钟的滑动时间窗口, * 窗口中有10个格子,每个格子100毫秒,每100毫秒移动一次,每次移动都需要记录当前服务请求的次数 */public class SlidingTimeWindow { //服务访问次数,可以放在Redis中,实现分布式系统的访问计数 Long counter = 0L; //使用LinkedList来记录滑动窗口的10个格子。 LinkedList<Long> slots = new LinkedList<Long>(); public static void main(String[] args) throws InterruptedException { SlidingTimeWindow timeWindow = new SlidingTimeWindow(); new Thread(new Runnable() { @Override public void run() { try { timeWindow.doCheck(); } catch (InterruptedException e) { e.printStackTrace(); } } }).start(); while (true){ //TODO 判断限流标记 timeWindow.counter++; Thread.sleep(new Random().nextInt(15)); } } private void doCheck() throws InterruptedException { while (true) { slots.addLast(counter); if (slots.size() > 10) { slots.removeFirst(); } //比较最后一个和第一个,两者相差100以上就限流 if ((slots.peekLast() - slots.peekFirst()) > 100) { System.out.println(\"限流了。。\"); //TODO 修改限流标记为true }else { //TODO 修改限流标记为false } Thread.sleep(100); } }}
滑动时间窗口算法
漏桶算法,又称leaky bucket。
从图中我们可以看到,整个算法其实十分简单。首先,我们有一个固定容量的桶,有水流进来,也有水流出去。对于流进来的水来说,我们无法预计一共有多少水会流进来,也无法预计水流的速度。但是对于流出去的水来说,这个桶可以固定水流出的速率。而且,当桶满了之后,多余的水将会溢出。我们将算法中的水换成实际应用中的请求,我们可以看到漏桶算法天生就限制了请求的速度。当使用了漏桶算法,我们可以保证接口会以一个常速速率来处理请求。所以漏桶算法天生不会出现临界问题。
漏桶算法
令牌桶算法,又称token bucket。同样为了理解该算法,我们来看一下该算法的示意图:
从图中我们可以看到,令牌桶算法比漏桶算法稍显复杂。首先,我们有一个固定容量的桶,桶里存放着令牌(token)。桶一开始是空的,token以 一个固定的速率r往桶里填充,直到达到桶的容量,多余的令牌将会被丢弃。每当一个请求过来时,就会尝试从桶里移除一个令牌,如果没有令牌的话,请求无法通过。具体的伪代码如下:
令牌桶算法
常见限流算法精讲
计数器 VS 滑动窗口:计数器算法是最简单的算法,可以看成是滑动窗口的低精度实现。滑动窗口由于需要存储多份的计数器(每一个格子存一份),所以滑动窗口在实现上需要更多的存储空间。也就是说,如果滑动窗口的精度越高,需要的存储空间就越大。
漏桶算法 VS 令牌桶算法:漏桶算法和令牌桶算法最明显的区别是令牌桶算法允许流量一定程度的突发。因为默认的令牌桶算法,取走token是不需要耗费时间的,也就是说,假设桶内有100个token时,那么可以瞬间允许100个请求通过。当然我们需要具体情况具体分析,只有最合适的算法,没有最优的算法。
限流算法小结
核心架构
Sentinel规则的推送有下面三种模式:推送模式 说明 优点 缺点原始模式 API 将规则推送至客户端并直接更新到内存中,扩展写数据源(WritableDataSource) \ 简单,无任何依赖 \ 不保证一致性;规则保存在内存中,重启即消失。严重不建议用于生产环境Pull 模式 扩展写数据源(WritableDataSource), 客户端主动向某个规则管理中心定期轮询拉取规则,这个规则中心可以是 RDBMS、文件 等 简单,无任何依赖;规则持久化 不保证一致性;实时性不保证,拉取过于频繁也可能会有性能问题。Push 模式扩展读数据源(ReadableDataSource),规则中心统一推送,客户端通过注册监听器的方式时刻监听变化,比如使用 Nacos、Zookeeper 等配置中心。这种方式有更好的实时性和一致性保证。生产环境下一般采用 push 模式的数据源。 规则持久化;一致性;快速 引入第三方依赖
如果不做任何修改,Dashboard 的推送规则方式是通过 API 将规则推送至客户端并直接更新到内存中:
这种做法的好处是简单,无依赖;坏处是应用重启规则就会消失,仅用于简单测试,不能用于生产环境。
1.1 原始模式
pull 模式的数据源(如本地文件、RDBMS 等)一般是可写入的。使用时需要在客户端注册数据源:将对应的读数据源注册至对应的 RuleManager,将写数据源注册至 transport 的 WritableDataSourceRegistry 中。
引入依赖<dependency> <groupId>com.alibaba.csp</groupId> <artifactId>sentinel-datasource-extension</artifactId> <version>1.8.4</version></dependency>核心代码:
官方demo: sentinel-demo/sentinel-demo-dynamic-file-rule
实现InitFunc接口,在init中处理DataSource初始化逻辑,并利用spi机制实现加载。
拉模式改造
1.2 拉模式
引入依赖<dependency> <groupId>com.alibaba.csp</groupId> <artifactId>sentinel-datasource-nacos</artifactId> <version>1.8.4</version></dependency>
核心代码// nacos server ipprivate static final String remoteAddress = \"localhost:8848\";// nacos groupprivate static final String groupId = \"Sentinel:Demo\";// nacos dataIdprivate static final String dataId = \"com.alibaba.csp.sentinel.demo.flow.rule\
nacos配置中心中配置流控规则[ { \"resource\": \"TestResource\
1.3.1 基于Nacos配置中心控制台实现推送官方demo: sentinel-demo-nacos-datasource
1)引入依赖 <!--sentinel持久化 采用 Nacos 作为规则配置数据源--><dependency> <groupId>com.alibaba.csp</groupId> <artifactId>sentinel-datasource-nacos</artifactId></dependency>
2)yml中配置spring: application: name: mall-user-sentinel-demo cloud: nacos: discovery: server-addr: 127.0.0.1:8848 sentinel: transport: # 添加sentinel的控制台地址 dashboard: 127.0.0.1:8080 # 指定应用与Sentinel控制台交互的端口,应用本地会起一个该端口占用的HttpServer port: 8719 datasource: ds1: nacos: server-addr: 127.0.0.1:8848 dataId: ${spring.application.name} groupId: DEFAULT_GROUP data-type: json rule-type: flow
源码参考com.alibaba.cloud.sentinel.datasource.config.AbstractDataSourceProperties#postRegister
[ { \"resource\": \"userinfo\
3)nacos配置中心中添加
微服务中通过yml配置实现SentinelProperties 内部提供了 TreeMap 类型的 datasource 属性用于配置数据源信息
思路一:微服务增加基于Nacos的写数据源(WritableDataSource),发布配置到nacos配置中心。
思路二:Sentinel Dashboard监听Nacos配置的变化,如发生变化就更新本地缓存。在Sentinel Dashboard端新增或修改规则配置在保存到内存的同时,直接发布配置到nacos配置中心;Sentinel Dashboard直接从nacos拉取所有的规则配置。Sentinel Dashboard和微服务不直接通信,而是通过nacos配置中心获取到配置的变更。
扩展改造的思路:
缺点:直接在Sentinel Dashboard中修改规则配置,配置中心的配置不会发生变化思考: 如何实现将通过sentinel控制台设置的规则直接持久化到nacos配置中心?
DynamicRuleProvider<T>: 拉取规则DynamicRulePublisher<T>: 推送规则
从 Sentinel 1.4.0 开始,Sentinel 控制台提供 DynamicRulePublisher 和 DynamicRuleProvider 接口用于实现应用维度的规则推送和拉取:
可以参考Sentinel Dashboard test包下的流控规则拉取和推送的实现逻辑:
注意:微服务接入Sentinel client,yml配置需要匹配对应的规则后缀
第1步:在com.alibaba.csp.sentinel.dashboard.rule包下创建nacos包,然后把各种场景的配置规则拉取和推送的实现类写到此包下
@GetMapping(\"/rules\
以流控规则为例,从Nacos配置中心获取所有的流控规则
@PostMapping(\"/rule\
新增流控规则,会推送到nacos配置中心
第2步:进入com.alibaba.csp.sentinel.dashboard.controller包下修改对应的规则controller实现类
引入依赖 <!--sentinel持久化 采用 Nacos 作为规则配置数据源--><dependency> <groupId>com.alibaba.csp</groupId> <artifactId>sentinel-datasource-nacos</artifactId></dependency>
增加yml配置server: port: 8806spring: application: name: mall-user-sentinel-rule-push-demo #微服务名称 #配置nacos注册中心地址 cloud: nacos: discovery: server-addr: 127.0.0.1:8848 sentinel: transport: # 添加sentinel的控制台地址 dashboard: 127.0.0.1:8080 # 指定应用与Sentinel控制台交互的端口,应用本地会起一个该端口占用的HttpServer #port: 8719 datasource:# ds1: #名称自定义,唯一# nacos:# server-addr: 127.0.0.1:8848# dataId: ${spring.application.name}# groupId: DEFAULT_GROUP# data-type: json# rule-type: flow flow-rules: nacos: server-addr: 127.0.0.1:8848 dataId: ${spring.application.name}-flow-rules groupId: SENTINEL_GROUP # 注意groupId对应Sentinel Dashboard中的定义 data-type: json rule-type: flow degrade-rules: nacos: server-addr: 127.0.0.1:8848 dataId: ${spring.application.name}-degrade-rules groupId: SENTINEL_GROUP data-type: json rule-type: degrade param-flow-rules: nacos: server-addr: 127.0.0.1:8848 dataId: ${spring.application.name}-param-flow-rules groupId: SENTINEL_GROUP data-type: json rule-type: param-flow authority-rules: nacos: server-addr: 127.0.0.1:8848 dataId: ${spring.application.name}-authority-rules groupId: SENTINEL_GROUP data-type: json rule-type: authority system-rules: nacos: server-addr: 127.0.0.1:8848 dataId: ${spring.application.name}-system-rules groupId: SENTINEL_GROUP data-type: json rule-type: system
以流控规则测试,当在sentinel dashboard配置了流控规则,会在nacos配置中心生成对应的配置。
测试:微服务接入改造后的Sentinel Dashboard
参见源码:com.alibaba.csp.sentinel.datasource.AbstractDataSource#loadConfig(S) 会解析配置规则。原因是:改造dashboard,提交到nacos配置中心的数据是ParamFlowRuleEntity类型,微服务拉取配置要解析的是ParamFlowRule类型,会导致规则解析丢失数据,造成热点规则不生效。 其他的规则原理也是一样,存在失效的风险。
注意:控制台改造后有可能出现规则不生效的情况,比如热点参数规则因为Converter解析json错误的原因会导致不生效。
[{ \"app\": \"mall-user-sentinel-rule-push-demo\
nacos配置中心保存的数据格式:
@Configurationpublic class ConverterConfig { @Bean(\"sentinel-json-param-flow-converter2\
自定义一个解析热点规则配置的解析器FlowParamJsonConverter,继承JsonConverter,重写convert方法。然后利用后置处理器替换beanName为\"param-flow-rules-sentinel-nacos-datasource\"的converter属性,注入FlowParamJsonConverter。
从配置中心拉取配置到控制台时,FlowRule转换为FlowRuleEntity
从控制台发布配置到配置中心时,FlowRuleEntity转换为FlowRule
2. 改造Sentinel Dashboard控制台,发布配置时将ParamFlowRuleEntity转成ParamFlowRule类型,再发布到Nacos配置中心。从配置中心拉取配置后将ParamFlowRule转成ParamFlowRuleEntity。
我提供两种解决思路:
热点参数规则失效和解决思路
Sentinel Dashboard改造
1.3.2 基于Sentinel控制台实现推送
1.3 推模式
Sentinel规则推送模式
https://www.processon.com/view/link/62e24778e0b34d06e56ab4b9
2. sentinel规则持久化部分源码分析
持久化实战
sentinel
分布式事务:https://www.processon.com/view/link/61cd52fb0e3e7441570801ab
大多数场景下,我们的应用都只需要操作单一的数据库,这种情况下的事务称之为本地事务(Local Transaction)。本地事务的ACID特性是数据库直接提供支持。本地事务应用架构如下所示:
1.1 本地事务
在微服务架构中,完成某一个业务功能可能需要横跨多个服务,操作多个数据库。这就涉及到到了分布式事务,需要操作的资源位于多个资源服务器上,而应用需要保证对于多个资源服务器的数据操作,要么全部成功,要么全部失败。本质上来说,分布式事务就是为了保证不同资源服务器的数据一致性。
跨库事务指的是,一个应用某个功能需要操作多个库,不同的库中存储不同的业务数据。下图演示了一个服务同时操作2个库的情况:
1) 跨库事务
通常一个库数据量比较大或者预期未来的数据量比较大,都会进行分库分表。如下图,将数据库B拆分成了2个库:
2) 分库分表
下图演示了一个3个服务之间彼此调用的微服务架构:
Service A完成某个功能需要直接操作数据库,同时需要调用Service B和Service C,而Service B又同时操作了2个数据库,Service C也操作了一个库。需要保证这些跨服务调用对多个数据库的操作要么都成功,要么都失败,实际上这可能是最典型的分布式事务场景。 小结:上述讨论的分布式事务场景中,无一例外的都直接或者间接的操作了多个数据库。如何保证事务的ACID特性,对于分布式事务实现方案而言,是非常大的挑战。同时,分布式事务实现方案还必须要考虑性能的问题,如果为了严格保证ACID特性,导致性能严重下降,那么对于一些要求快速响应的业务,是无法接受的。
3) 微服务架构
典型的分布式事务应用场景
1.2 分布式事务
两阶段提交(Two Phase Commit),就是将提交(commit)过程划分为2个阶段(Phase):
TM通知各个RM准备提交它们的事务分支。如果RM判断自己进行的工作可以被提交,那就对工作内容进行持久化,再给TM肯定答复;要是发生了其他情况,那给TM的都是否定答复。以mysql数据库为例,在第一阶段,事务管理器向所有涉及到的数据库服务器发出prepare\"准备提交\"请求,数据库收到请求后执行数据修改和日志记录等处理,处理完成后只是把事务的状态改成\"可以提交\
阶段1:
TM根据阶段1各个RM prepare的结果,决定是提交还是回滚事务。如果所有的RM都prepare成功,那么TM通知所有的RM进行提交;如果有RM prepare失败的话,则TM通知所有RM回滚自己的事务分支。
以mysql数据库为例,如果第一阶段中所有数据库都prepare成功,那么事务管理器向数据库服务器发出\"确认提交\"请求,数据库服务器把事务的\"可以提交\"状态改为\"提交完成\"状态,然后返回应答。如果在第一阶段内有任何一个数据库的操作发生了错误,或者事务管理器收不到某个数据库的回应,则认为事务失败,回撤所有数据库的事务。数据库服务器收不到第二阶段的确认提交请求,也会把\"可以提交\"的事务回撤。
两阶段提交方案下全局事务的ACID特性,是依赖于RM的。一个全局事务内部包含了多个独立的事务分支,这一组事务分支要么都成功,要么都失败。各个事务分支的ACID特性共同构成了全局事务的ACID特性。也就是将单个事务分支支持的ACID特性提升一个层次到分布式事务的范畴。
阶段2
同步阻塞问题2PC 中的参与者是阻塞的。在第一阶段收到请求后就会预先锁定资源,一直到 commit 后才会释放。
单点故障由于协调者的重要性,一旦协调者TM发生故障,参与者RM会一直阻塞下去。尤其在第二阶段,协调者发生故障,那么所有的参与者还都处于锁定事务资源的状态中,而无法继续完成事务操作。
数据不一致若协调者第二阶段发送提交请求时崩溃,可能部分参与者收到commit请求提交了事务,而另一部分参与者未收到commit请求而放弃事务,从而造成数据不一致的问题。
2PC存在的问题
1.3 两阶段提交协议(2PC)
分布式事务简介
Seata 是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。Seata 将为用户提供了 AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案。AT模式是阿里首推的模式,阿里云上有商用版本的GTS(Global Transaction Service 全局事务服务)官网:https://seata.io/zh-cn/index.html源码: https://github.com/seata/seataseata版本:v1.5.1
在 Seata 的架构中,一共有三个角色: TC (Transaction Coordinator) - 事务协调者维护全局和分支事务的状态,驱动全局事务提交或回滚。TM (Transaction Manager) - 事务管理器定义全局事务的范围:开始全局事务、提交或回滚全局事务。RM (Resource Manager) - 资源管理器管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。其中,TC 为单独部署的 Server 服务端,TM 和 RM 为嵌入到应用中的 Client 客户端。
2.1 Seata的三大角色
Seata AT模式的核心是对业务无侵入,是一种改进后的两阶段提交,其设计思路如下:一阶段:业务数据和回滚日志记录在同一个本地事务中提交,释放本地锁和连接资源。二阶段:提交异步化,非常快速地完成。回滚通过一阶段的回滚日志进行反向补偿。
业务数据和回滚日志记录在同一个本地事务中提交,释放本地锁和连接资源。核心在于对业务sql进行解析,转换成undolog,并同时入库,这是怎么做的呢?
一阶段
分布式事务操作成功,则TC通知RM异步删除undolog
分布式事务操作失败,TM向TC发送回滚请求,RM 收到协调器TC发来的回滚请求,通过 XID 和 Branch ID 找到相应的回滚日志记录,通过回滚记录生成反向的更新 SQL 并执行,以完成分支的回滚。
二阶段
2.2 Seata AT模式的设计思路
Seata是什么
Seata分TC、TM和RM三个角色,TC(Server端)为单独服务端部署,TM和RM(Client端)由业务系统集成。
资源目录:https://github.com/seata/seata/tree/v1.5.1/scriptclient 存放client端sql脚本,参数配置config-center各个配置中心参数导入脚本,config.txt(包含server和client)为通用参数文件serverserver端数据库脚本及各个容器配置
步骤一:下载安装包https://github.com/seata/seata/releases
步骤二:建表(db模式)创建数据库seata,执行sql脚本,https://github.com/seata/seata/tree/v1.5.1/script/server/db
Seata支持哪些注册中心?eurekaconsulnacosetcdzookeepersofaredisfile (直连)
注意:请确保client与server的注册处于同一个namespace和group,不然会找不到服务。
启动 Seata-Server 后,会发现Server端的服务出现在 Nacos 控制台中的注册中心列表中。
配置将Seata Server注册到Nacos,修改conf/application.yml文件
步骤三:配置Nacos注册中心
配置中心可以说是一个\"大货仓\
1)配置Nacos配置中心地址,修改conf/application.yml文件
https://github.com/seata/seata/tree/v1.5.1/script/config-center
store.mode=dbstore.lock.mode=dbstore.session.mode=dbstore.db.driverClassName=com.mysql.jdbc.Driverstore.db.url=jdbc:mysql://127.0.0.1:3306/seata?useUnicode=true&rewriteBatchedStatements=truestore.db.user=rootstore.db.password=root
a) 获取/seata/script/config-center/config.txt,修改为db存储模式,并修改mysql连接配置
事务分组:seata的资源逻辑,可以按微服务的需要,在应用程序(客户端)对自行定义事务分组,每组取一个名字。集群:seata-server服务端一个或多个节点组成的集群cluster。 应用程序(客户端)使用时需要指定事务逻辑分组与Seata服务端集群的映射关系。
首先应用程序(客户端)中配置了事务分组(GlobalTransactionScanner 构造方法的txServiceGroup参数)。若应用程序是SpringBoot则通过seata.tx-service-group 配置。应用程序(客户端)会通过用户配置的配置中心去寻找service.vgroupMapping .[事务分组配置项],取得配置项的值就是TC集群的名称。若应用程序是SpringBoot则通过seata.service.vgroup-mapping.事务分组名=集群名称 配置拿到集群名称程序通过一定的前后缀+集群名称去构造服务名,各配置中心的服务名实现不同(前提是Seata-Server已经完成服务注册,且Seata-Server向注册中心报告cluster名与应用程序(客户端)配置的集群名称一致)拿到服务名去相应的注册中心去拉取相应服务名的服务列表,获得后端真实的TC服务列表(即Seata-Server集群节点列表)
事务分组如何找到后端Seata集群(TC)?
b) 配置事务分组, 要与client配置的事务分组一致
c) 在nacos配置中心中新建配置,dataId为seataServer.properties,配置内容为上面修改后的config.txt中的配置信息
2)上传配置至Nacos配置中心
步骤四:配置Nacos配置中心
步骤五:启动Seata Server
db存储模式+Nacos(注册&配置中心)方式部署
3.1 Seata Server(TC)环境搭建
用户下单,整个业务逻辑由三个微服务构成:库存服务:对给定的商品扣除库存数量。订单服务:根据采购需求创建订单。帐户服务:从用户帐户中扣除余额。
业务场景
父pom指定微服务版本Spring Cloud Alibaba Version Spring Cloud Version Spring Boot Version Seata Version2.2.8.RELEASE Spring Cloud Hoxton.SR1 22.3.12.RELEASE 1.5.1启动Seata Server(TC)端,Seata Server使用nacos作为配置中心和注册中心启动nacos服务
1) 环境准备
spring-cloud-starter-alibaba-seata内部集成了seata,并实现了xid传递<!-- seata--><dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-seata</artifactId></dependency>
2) 微服务导入seata依赖
3)微服务对应数据库中添加undo_log表(仅AT模式)
seata: application-id: ${spring.application.name} # seata 服务分组,要与服务端配置service.vgroup_mapping的后缀对应 tx-service-group: default_tx_group registry: # 指定nacos作为注册中心 type: nacos nacos: application: seata-server server-addr: 127.0.0.1:8848 namespace: group: SEATA_GROUP config: # 指定nacos作为配置中心 type: nacos nacos: server-addr: 127.0.0.1:8848 namespace: 7e838c12-8554-4231-82d5-6d93573ddf32 group: SEATA_GROUP data-id: seataServer.properties注意:请确保client与server的注册中心和配置中心namespace和group一致
4) 微服务application.yml中添加seata配置
核心代码@Override@GlobalTransactional(name=\"createOrder\
5) 在全局事务发起者中添加@GlobalTransactional注解
Spring Cloud Alibaba整合Seata AT模式实战
3.2 Seata Client快速开始
Seata快速开始
在 Seata 定义的分布式事务框架内,利用事务资源(数据库、消息服务等)对 XA 协议的支持,以 XA 协议的机制来管理分支事务的一种 事务模式。
AT和XA模式数据源代理机制对比
从编程模型上,XA 模式与 AT 模式保持完全一致。只需要修改数据源代理,即可实现 XA 模式与 AT 模式之间的切换。@Bean(\"dataSource\")public DataSource dataSource(DruidDataSource druidDataSource) { // DataSourceProxy for AT mode // return new DataSourceProxy(druidDataSource); // DataSourceProxyXA for XA mode return new DataSourceProxyXA(druidDataSource);}
XA 模式的使用
1.1 整体机制
对比Seata AT模式配置,只需修改两个地方:微服务数据库不需要undo_log表,undo_log表仅用于AT模式修改数据源代码模式为XA模式
seata: # 数据源代理模式 默认AT data-source-proxy-mode: XA
1.2 Spring Cloud Alibaba整合Seata XA实战
Seata XA模式
TCC 基于分布式事务中的二阶段提交协议实现,它的全称为 Try-Confirm-Cancel,即资源预留(Try)、确认操作(Confirm)、取消操作(Cancel),他们的具体含义如下:Try:对业务资源的检查并预留;Confirm:对业务处理进行提交,即 commit 操作,只要 Try 成功,那么该步骤一定成功;Cancel:对业务处理进行取消,即回滚操作,该步骤回对 Try 预留的资源进行释放。
XA是资源层面的分布式事务,强一致性,在两阶段提交的整个过程中,一直会持有资源的锁。TCC是业务层面的分布式事务,最终一致性,不会一直持有资源的锁。
TCC 是一种侵入式的分布式事务解决方案,以上三个操作都需要业务系统自行实现,对业务系统有着非常大的入侵性,设计相对复杂,但优点是 TCC 完全不依赖数据库,能够实现跨数据库、跨应用资源管理,对这些不同数据访问通过侵入式的编码方式实现一个原子操作,更好地解决了在各种复杂业务场景下的分布式事务问题。
try 阶段首先进行预留资源,然后在 commit 阶段扣除资源。如下图:
try-commit
try 阶段首先进行预留资源,预留资源时扣减库存失败导致全局事务回滚,在 cancel 阶段释放资源。如下图:
try-cancel
以用户下单为例
一个分布式的全局事务,整体是 两阶段提交 的模型。全局事务是由若干分支事务组成的,分支事务要满足 两阶段提交 的模型要求,即需要每个分支事务都具备自己的:
一阶段 prepare 行为二阶段 commit 或 rollback 行为
在Seata中,AT模式与TCC模式事实上都是两阶段提交的具体实现,他们的区别在于:AT 模式基于 支持本地 ACID 事务的关系型数据库:一阶段 prepare 行为:在本地事务中,一并提交业务数据更新和相应回滚日志记录。二阶段 commit 行为:马上成功结束,自动异步批量清理回滚日志。二阶段 rollback 行为:通过回滚日志,自动生成补偿操作,完成数据回滚。相应的,TCC 模式不依赖于底层数据资源的事务支持:一阶段 prepare 行为:调用自定义的 prepare 逻辑。二阶段 commit 行为:调用自定义的 commit 逻辑。二阶段 rollback 行为:调用自定义的 rollback 逻辑。简单点概括,SEATA的TCC模式就是手工的AT模式,它允许你自定义两阶段的处理逻辑而不依赖AT模式的undo_log。
2.1 Seata TCC 模式
public interface TccActionOne { @TwoPhaseBusinessAction(name = \"prepare\
假设现有一个业务需要同时使用服务 A 和服务 B 完成一个事务操作,我们在服务 A 定义该服务的一个 TCC 接口:
public interface TccActionTwo { @TwoPhaseBusinessAction(name = \"prepare\
同样,在服务 B 定义该服务的一个 TCC 接口:
在业务所在系统中开启全局事务并执行服务 A 和服务 B 的 TCC 预留资源方法:
以上就是使用 Seata TCC 模式实现一个全局事务的例子,TCC 模式同样使用 @GlobalTransactional 注解开启全局事务,而服务 A 和服务 B 的 TCC 接口为事务参与者,Seata 会把一个 TCC 接口当成一个 Resource,也叫 TCC Resource。
2.2 Seata TCC模式接口如何改造
在 TCC 模型执行的过程中,还可能会出现各种异常,其中最为常见的有空回滚、幂等、悬挂等。TCC 模式是分布式事务中非常重要的事务模式,但是幂等、悬挂和空回滚一直是 TCC 模式需要考虑的问题,Seata 框架在 1.5.1 版本完美解决了这些问题。
空回滚指的是在一个分布式事务中,在没有调用参与方的 Try 方法的情况下,TM 驱动二阶段回滚调用了参与方的 Cancel 方法。
如上图所示,全局事务开启后,参与者 A 分支注册完成之后会执行参与者一阶段 RPC 方法,如果此时参与者 A 所在的机器发生宕机,网络异常,都会造成 RPC 调用失败,即参与者 A 一阶段方法未成功执行,但是此时全局事务已经开启,Seata 必须要推进到终态,在全局事务回滚时会调用参与者 A 的 Cancel 方法,从而造成空回滚。
那么空回滚是如何产生的呢?
Seata 的做法是新增一个 TCC 事务控制表,包含事务的 XID 和 BranchID 信息,在 Try 方法执行时插入一条记录,表示一阶段执行了,执行 Cancel 方法时读取这条记录,如果记录不存在,说明 Try 方法没有执行。
要想防止空回滚,那么必须在 Cancel 方法中识别这是一个空回滚,Seata 是如何做的呢?
如何处理空回滚
幂等问题指的是 TC 重复进行二阶段提交,因此 Confirm/Cancel 接口需要支持幂等处理,即不会产生资源重复提交或者重复释放。
如上图所示,参与者 A 执行完二阶段之后,由于网络抖动或者宕机问题,会造成 TC 收不到参与者 A 执行二阶段的返回结果,TC 会重复发起调用,直到二阶段执行结果成功。
那么幂等问题是如何产生的呢?
同样的也是在 TCC 事务控制表中增加一个记录状态的字段 status,该字段有 3 个值,分别为:tried:1committed:2rollbacked:3
二阶段 Confirm/Cancel 方法执行后,将状态改为 committed 或 rollbacked 状态。当重复调用二阶段 Confirm/Cancel 方法时,判断事务状态即可解决幂等问题。
Seata 是如何处理幂等问题的呢?
如何处理幂等
悬挂指的是二阶段 Cancel 方法比 一阶段 Try 方法优先执行,由于允许空回滚的原因,在执行完二阶段 Cancel 方法之后直接空回滚返回成功,此时全局事务已结束,但是由于 Try 方法随后执行,这就会造成一阶段 Try 方法预留的资源永远无法提交和释放了。
如上图所示,在执行参与者 A 的一阶段 Try 方法时,出现网路拥堵,由于 Seata 全局事务有超时限制,执行 Try 方法超时后,TM 决议全局回滚,回滚完成后如果此时 RPC 请求才到达参与者 A,执行 Try 方法进行资源预留,从而造成悬挂。
那么悬挂是如何产生的呢?
在 TCC 事务控制表记录状态的字段 status 中增加一个状态:suspended:4当执行二阶段 Cancel 方法时,如果发现 TCC 事务控制表有相关记录,说明二阶段 Cancel 方法优先一阶段 Try 方法执行,因此插入一条 status=4 状态的记录,当一阶段 Try 方法后面执行时,判断 status=4 ,则说明有二阶段 Cancel 已执行,并返回 false 以阻止一阶段 Try 方法执行成功。
Seata 是怎么处理悬挂的呢?
如何处理悬挂
2.3 TCC如何控制异常
父pom指定微服务版本Spring Cloud Alibaba Version Spring Cloud Version Spring Boot Version Seata Version2.2.8.RELEASE Spring Cloud Hoxton.SR1 22.3.12.RELEASE 1.5.1启动Seata Server(TC)端,Seata Server使用nacos作为配置中心和注册中心启动nacos服务
3)微服务application.yml中添加seata配置
TCC相关注解如下:@LocalTCC 适用于SpringCloud+Feign模式下的TCC,@LocalTCC一定需要注解在接口上,此接口可以是寻常的业务接口,只要实现了TCC的两阶段提交对应方法便可@TwoPhaseBusinessAction 注解try方法,其中name为当前tcc方法的bean名称,写方法名便可(全局唯一),commitMethod指向提交方法,rollbackMethod指向事务回滚方法。指定好三个方法之后,seata会根据全局事务的成功或失败,去帮我们自动调用提交方法或者回滚方法。@BusinessActionContextParameter 注解可以将参数传递到二阶段(commitMethod/rollbackMethod)的方法。BusinessActionContext 便是指TCC事务上下文
TCC 幂等、悬挂和空回滚问题如何解决?TCC 模式中存在的三大问题是幂等、悬挂和空回滚。在 Seata1.5.1 版本中,增加了一张事务控制表,表名是 tcc_fence_log 来解决这个问题。而在@TwoPhaseBusinessAction 注解中提到的属性 useTCCFence 就是来指定是否开启这个机制,这个属性值默认是 false。
4)定义TCC接口
5)微服务增加tcc_fence_log日志表
6)TCC接口的业务实现
@GlobalTransactional(name=\"createOrder\
7) 在全局事务发起者中添加@GlobalTransactional注解
2.4 Spring Cloud Alibaba整合Seata TCC实战
什么是TCC
TransactionManager
TransactionManagerHolder为创建单例TransactionManager的工厂,可以使用EnhancedServiceLoader的spi机制加载用户自定义的类,默认为DefaultTransactionManager。
DefaultTransactionManager
GlobalTransaction接口提供给用户开启事务,提交,回滚,获取状态等方法。
GlobalTransaction
DefaultGlobalTransaction是GlobalTransaction接口的默认实现,它持有TransactionManager对象,默认开启事务超时时间为60秒,默认名称为default,因为调用者的业务方法可能多重嵌套创建多个GlobalTransaction对象开启事务方法,因此GlobalTransaction有GlobalTransactionRole角色属性,只有Launcher角色的才有开启、提交、回滚事务的权利。
DefaultGlobalTransaction
GlobalTransactionContext为操作GlobalTransaction的工具类,提供创建新的GlobalTransaction,获取当前线程有的GlobalTransaction等方法。
GlobalTransactionContext
GlobalTransactionScanner继承AbstractAutoProxyCreator类,即实现了SmartInstantiationAwareBeanPostProcessor接口,会在spring容器启动初始化bean的时候,对bean进行代理操作。wrapIfNecessary为继承父类代理bean的核心方法,如果用户配置了service.disableGlobalTransaction为false属性则注解不生效直接返回,否则对GlobalTransactional或GlobalLock的方法进行拦截代理。
GlobalTransactionScanner
GlobalTransactionalInterceptor实现aop的MethodInterceptor接口,对有@GlobalTransactional或GlobalLock注解的方法进行代理。
GlobalTransactionalInterceptor
TransactionalTemplate模板类提供了一个开启事务,执行业务,成功提交和失败回滚的模板方法execute(TransactionalExecutor business)。
TransactionalTemplate
DefaultCoordinator即为TC,全局事务默认的事务协调器。它继承AbstractTCInboundHandler接口,为TC接收RM和TM的request请求数据,是进行相应处理的处理器。实现TransactionMessageHandler接口,去处理收到的RPC信息。实现ResourceManagerInbound接口,发送至RM的branchCommit,branchRollback请求。
DefaultCoordinator
Core
GlobalSession是seata协调器DefaultCoordinator管理维护的重要部件,当用户开启全局分布式事务,TM调用begin方法请求至TC,TC则创建GlobalSession实例对象,返回唯一的xid。它实现SessionLifecycle接口,提供begin,changeStatus,changeBranchStatus,addBranch,removeBranch等操作session和branchSession的方法。BranchSession
GlobalSession
BranchSession为分支session,管理分支数据,受globalSession统一调度管理,它的lock和unlock方法由lockManger实现。
BranchSession
DefaultLockManager是LockManager的默认实现,它获取branchSession的lockKey,转换成List<RowLock>,委派Locker进行处理。
LockManager
Locker接口提供根据行数据获取锁,释放锁,是否锁住和清除所有锁的方法。
Locker
ResourceManager是seata的重要组件之一,RM负责管理分支数据资源的事务。
AbstractResourceManager实现ResourceManager提供模板方法。DefaultResourceManager适配所有的ResourceManager,所有方法调用都委派给对应负责的ResourceManager处理。
ResourceManager
此为AT模式核心管理器,DataSourceManager继承AbstractResourceManager,管理数据库Resouce的注册,提交以及回滚等
DataSourceManager
DataSourceManager事务提交委派给AsyncWorker进行提交的,因为都成功了,无需回滚成功的数据,只需要删除生成的操作日志就行,采用异步方式,提高效率。
AsyncWorker
UndoLogManager
Resource能被ResourceManager管理并且能够关联GlobalTransaction。
Resource
DataSourceProxy
ConnectionProxy
ExecuteTemplate为具体statement的execute,executeQuery和executeUpdate执行提供模板方法Executor
ExecuteTemplate
SQLRecognizer识别sql类型,获取表名,表别名以及原生sql
SQLRecognizer
UndoExecutorFactory根据sqlType生成对应的AbstractUndoExecutor。UndoExecutor为生成执行undoSql的核心。如果全局事务回滚,它会根据beforeImage和afterImage以及sql类型生成对应的反向sql执行回滚数据,并添加脏数据校验机制,使回滚数据更加可靠。
UndoExecutorFactory
1. Seata核心接口和实现类
Seata设计流程: https://www.processon.com/view/link/6311bfda1e0853187c0ecd8c
https://www.processon.com/view/link/6007f5c00791294a0e9b611ahttps://www.processon.com/view/link/5f743063e0b34d0711f001d2
2. 源码分析
源码分析
seata
API网关是随着微服务概念兴起的一种架构模式,它是运行于外部请求与内部服务之间的一个流量入口,用于实现对外部请求的协议转换、鉴权、流控、参数校验、监控等通用功能。
在微服务架构中,通常一个系统会被拆分为多个微服务,面对这么多微服务客户端应该如何去调用呢?如果根据每个微服务的地址发起调用,存在如下问题:客户端多次请求不同的微服务,会增加客户端代码和配置的复杂性,维护成本比价高认证复杂,每个微服务可能存在不同的认证方式,客户端去调用,要去适配不同的认证存在跨域的请求,调用链有一定的相对复杂性(防火墙 / 浏览器不友好的协议)难以重构,随着项目的迭代,可能需要重新划分微服务
为了解决上面的问题,微服务引入了 API网关 的概念,API网关为微服务架构的系统提供简单、有效且统一的API路由管理,作为系统的统一入口,提供内部服务的路由中转,给客户端提供统一的服务,可以实现一些和业务没有耦合的公用逻辑,主要功能包含认证、鉴权、路由转发、安全策略、防刷、流量控制、监控日志等。
1.1 背景
Spring Cloud Gateway 是Spring Cloud官方推出的第二代网关框架,定位于取代 Netflix Zuul。Spring Cloud Gateway 旨在为微服务架构提供一种简单且有效的 API 路由的管理方式,并基于 Filter 的方式提供网关的基本功能,例如说安全认证、监控、限流等等。Spring Cloud Gateway 是由 WebFlux + Netty + Reactor 实现的响应式的 API 网关。它不能在传统的 servlet 容器中工作,也不能构建成 war 包。官网文档:https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/
路由(route) 路由是网关中最基础的部分,路由信息包括一个ID、一个目的URI、一组断言工厂、一组Filter组成。断言(predicates) Java8中的断言函数,SpringCloud Gateway中的断言函数类型是Spring5.0框架中的ServerWebExchange。断言函数允许开发者去定义匹配Http request中的任何信息,比如请求头和参数等。如果断言为真,则说明请求的URL和配置的路由匹配。过滤器(Filter) SpringCloud Gateway中的filter分为Gateway FilIer和Global Filter。Filter可以对请求和响应进行处理。
1.2.1 核心概念
Spring Cloud Gateway 的工作原理跟 Zuul 的差不多,最大的区别就是 Gateway 的 Filter 只有 pre 和 post 两种。
客户端向 Spring Cloud Gateway 发出请求,如果请求与网关程序定义的路由匹配,则该请求就会被发送到网关 Web 处理程序,此时处理程序运行特定的请求过滤器链。过滤器之间用虚线分开的原因是过滤器可能会在发送代理请求的前后执行逻辑。所有 pre 过滤器逻辑先执行,然后执行代理请求;代理请求完成后,执行 post 过滤器逻辑。
1.2.2 工作原理
1.2 什么是Spring Cloud Gateway
1.什么是API网关
<!-- gateway网关 --><dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-gateway</artifactId></dependency><!-- nacos服务注册与发现 --><dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId></dependency>
注意:会和spring-webmvc的依赖冲突,需要排除spring-webmvc
1) 引入依赖
2) 编写yml配置文件
2.1 微服务接入Spring Cloud Gateway
predicates:路由断言,判断请求是否符合要求,符合则转发到路由目的地。application.yml配置文件中写的断言规则只是字符串,这些字符串会被Predicate Factory读取并处理,转变为路由判断的条件文档:https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#gateway-request-predicates-factories
通过网关启动日志,可以查看内置路由断言工厂:
spring: cloud: gateway: #设置路由:路由id、路由到微服务的uri、断言 routes: - id: order_route #路由ID,全局唯一 uri: lb://mall-order #目标微服务的请求地址和端口 predicates: # 测试:http://localhost:8888/order/findOrderByUserId/1 - Path=/order/** # 断言,路径相匹配的进行路由
2.2.1 路径匹配
2.2.2 Header匹配
2.2 路由断言工厂(Route Predicate Factories)配置
GatewayFilter是网关中提供的一种过滤器,可以对进入网关的请求和微服务返回的响应做处理https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#gatewayfilter-factories
需求:给所有进入mall-order的请求添加一个请求头:X-Request-color=red。只需要修改gateway服务的application.yml文件,添加路由过滤即可:
2.3.1 添加请求头
2.3.2 添加请求参数
继承AbstractNameValueGatewayFilterFactory且我们的自定义名称必须要以GatewayFilterFactory结尾并交给spring管理。
配置自定义的过滤器工厂
2.3.3 自定义过滤器工厂
2.3 过滤器工厂( GatewayFilter Factories)配置
全局过滤器的作用也是处理一切进入网关的请求和微服务响应,与GatewayFilter的作用一样。GatewayFilter:网关过滤器,需要通过spring.cloud.routes.filters配置在具体的路由下,只作用在当前特定路由上,也可以通过配置spring.cloud.default-filters让它作用于全局路由上。GlobalFilter:全局过滤器,不需要再配置文件中配置,作用在所有的路由上,最终通过GatewayFilterAdapter包装成GatewayFilterChain能够识别的过滤器。
LoadBalancerClientFilter 会查看exchange的属性 ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR 的值(一个URI),如果该值的scheme是 lb,比如:lb://myservice ,它将会使用Spring Cloud的LoadBalancerClient 来将 myservice 解析成实际的host和port,并替换掉 ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR 的内容。
其实就是用来整合负载均衡器Ribbon的
spring: cloud: gateway: routes: - id: order_route uri: lb://mall-order predicates: - Path=/order/**
2.4.1 LoadBalancerClientFilter
自定义全局过滤器定义方式是实现GlobalFilter接口。每一个过滤器都必须指定一个int类型的order值,order值越小,过滤器优先级越高,执行顺序越靠前。GlobalFilter通过实现Ordered接口来指定order值
2.4.2 自定义全局过滤器
2.4 全局过滤器(Global Filters)配置
在前端领域中,跨域是指浏览器允许向服务器发送跨域请求,从而克服Ajax只能同源使用的限制。同源策略(Same Orgin Policy)是一种约定,它是浏览器核心也最基本的安全功能,它会阻止一个域的js脚本和另外一个域的内容进行交互,如果缺少了同源策略,浏览器很容易受到XSS、CSFR等攻击。所谓同源(即在同一个域)就是两个页面具有相同的协议(protocol)、主机(host)和端口号(port)。
https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#cors-configurationspring: cloud: gateway: globalcors: cors-configurations: '[/**]': allowedOrigins: \"*\" allowedMethods: - GET - POST - DELETE - PUT - OPTION
通过yml配置的方式
通过java配置的方式@Configurationpublic class CorsConfig { @Bean public CorsWebFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedMethod(\"*\"); config.addAllowedOrigin(\"*\"); config.addAllowedHeader(\"*\"); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(new PathPatternParser()); source.registerCorsConfiguration(\"/**\
通过java配置的方式
如何解决gateway跨域问题?
2.5 Gateway跨域配置(CORS Configuration)
spring cloud alibaba官方提供了RequestRateLimiter过滤器工厂,基于redis+lua脚本方式采用令牌桶算法实现了限流。https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#the-requestratelimiter-gatewayfilter-factory
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis-reactive</artifactId></dependency><dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId></dependency>
1)添加依赖
2)修改 application.yml ,添加redis配置和RequestRateLimiter过滤器工厂配置
@BeanKeyResolver keyResolver() { //url限流 return exchange -> Mono.just(exchange.getRequest().getURI().getPath()); //参数限流 //return exchange -> Mono.just(exchange.getRequest().getQueryParams().getFirst(\"user\"));}
3) 配置keyResolver,可以指定限流策略,比如url限流,参数限流,ip限流等等
2.6 Gateway基于redis+lua脚本限流
<!-- gateway接入sentinel --><dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-alibaba-sentinel-gateway</artifactId></dependency><dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId></dependency><--sentinel规则持久化到nacos--><dependency> <groupId>com.alibaba.csp</groupId> <artifactId>sentinel-datasource-nacos</artifactId></dependency>
2)添加yml配置,接入sentinel dashboard,通过sentinel控制台配置网关流控规则
2.7.2 Sentinel网关流控实现原理
2.7 Gateway整合sentinel限流
2. Spring Cloud Gateway实战
总览图
Gateway
OAuth(Open Authorization)是一个关于授权(authorization)的开放网络标准,允许用户授权第三方应用访问他们存储在另外的服务提供者上的信息,而不需要将用户名和密码提供给第三方移动应用或分享他们数据的所有内容。OAuth在全世界得到广泛应用,目前的版本是2.0版。OAuth协议:https://tools.ietf.org/html/rfc6749
协议特点:简单:不管是OAuth服务提供者还是应用开发者,都很易于理解与使用;安全:没有涉及到用户密钥等信息,更安全更灵活;开放:任何服务提供商都可以实现OAuth,任何软件开发商都可以使用OAuth
原生app授权:app登录请求后台接口,为了安全认证,所有请求都带token信息,如果登录验证、请求后台数据。前后端分离单页面应用:前后端分离框架,前端请求后台数据,需要进行oauth2安全认证,比如使用vue、react后者h5开发的app第三方应用授权登录,比如QQ,微博,微信的授权登录。
有一个\"云冲印\"的网站,可以将用户储存在Google的照片,冲印出来。用户为了使用该服务,必须让\"云冲印\"读取自己储存在Google上的照片。只有得到用户的授权,Google才会同意\"云冲印\"读取这些照片。那么,\"云冲印\"怎样获得用户的授权呢?传统方法是,用户将自己的Google用户名和密码,告诉\"云冲印\",后者就可以读取用户的照片了。这样的做法有以下几个严重的缺点:\"云冲印\"为了后续的服务,会保存用户的密码,这样很不安全。Google不得不部署密码登录,而我们知道,单纯的密码登录并不安全。\"云冲印\"拥有了获取用户储存在Google所有资料的权力,用户没法限制\"云冲印\"获得授权的范围和有效期。用户只有修改密码,才能收回赋予\"云冲印\"的权力。但是这样做,会使得其他所有获得用户授权的第三方应用程序全部失效。只要有一个第三方应用程序被破解,就会导致用户密码泄漏,以及所有被密码保护的数据泄漏。
生活中常见的oauth2场景,京东商城(https://www.jd.com/)接入微信开放平台,可以通过微信登录。
登录流程分析:https://www.processon.com/view/link/60a32e7a079129157118740f
微信开发平台文档:https://developers.weixin.qq.com/doc/oplatform/Mobile_App/WeChat_Login/Development_Guide.html
1.1 应用场景
(1)Third-party application:第三方应用程序,又称\"客户端\"(client),即例子中的\"云冲印\"。(2)HTTP service:HTTP服务提供商,简称\"服务提供商\",即例子中的Google。(3)Resource Owner:资源所有者,又称\"用户\"(user)。(4)User Agent:用户代理,比如浏览器。(5)Authorization server:授权服务器,即服务提供商专门用来处理认证授权的服务器。(6)Resource server:资源服务器,即服务提供商存放用户生成的资源的服务器。它与授权服务器,可以是同一台服务器,也可以是不同的服务器。OAuth的作用就是让\"客户端\"安全可控地获取\"用户\"的授权,与\"服务提供商\"进行交互。
1.2 基本概念
优点:更安全,客户端不接触用户密码,服务器端更易集中保护广泛传播并被持续采用短寿命和封装的token资源服务器和授权服务器解耦集中式授权,简化客户端HTTP/JSON友好,易于请求和传递token考虑多种客户端架构场景客户可以具有不同的信任级别 缺点:协议框架太宽泛,造成各种实现的兼容性和互操作性差不是一个认证协议,本身并不能告诉你任何用户信息。
1.3 优缺点
1. OAuth2.0介绍
OAuth在\"客户端\"与\"服务提供商\"之间,设置了一个授权层(authorization layer)。\"客户端\"不能直接登录\"服务提供商\",只能登录授权层,以此将用户与客户端区分开来。\"客户端\"登录授权层所用的令牌(token),与用户的密码不同。用户可以在登录的时候,指定授权层令牌的权限范围和有效期,\"客户端\"登录授权层以后,\"服务提供商\"根据令牌的权限范围和有效期,向\"客户端\"开放用户储存的资料。
(A)用户打开客户端以后,客户端要求用户给予授权。(B)用户同意给予客户端授权。(C)客户端使用上一步获得的授权,向授权服务器申请令牌。(D)授权服务器对客户端进行认证以后,确认无误,同意发放令牌。(E)客户端使用令牌,向资源服务器申请获取资源。(F)资源服务器确认令牌无误,同意向客户端开放资源。
令牌(token)与密码(password)的作用是一样的,都可以进入系统,但是有三点差异。(1)令牌是短期的,到期会自动失效,用户自己无法修改。密码一般长期有效,用户不修改,就不会发生变化。(2)令牌可以被数据所有者撤销,会立即失效。密码一般不允许被他人撤销。(3)令牌有权限范围(scope)。对于网络服务来说,只读令牌就比读写令牌更安全。密码一般是完整权限。上面这些设计,保证了令牌既可以让第三方应用获得权限,同时又随时可控,不会危及系统安全。这就是 OAuth 2.0 的优点。
OAuth 2.0的运行流程如下图,摘自RFC 6749:
客户端必须得到用户的授权(authorization grant),才能获得令牌(access token)。OAuth 2.0 对于如何颁发令牌的细节,规定得非常详细。具体来说,一共分成四种授权类型(authorization grant),即四种颁发令牌的方式,适用于不同的互联网场景。
授权码模式(authorization code)密码模式(resource owner password credentials)简化(隐式)模式(implicit)客户端模式(client credentials)
不管哪一种授权方式,第三方应用申请令牌之前,都必须先到系统备案,说明自己的身份,然后会拿到两个身份识别码:客户端 ID(client ID)和客户端密钥(client secret)。这是为了防止令牌被滥用,没有备案过的第三方应用,是不会拿到令牌的。
授权码(authorization code)方式,指的是第三方应用先申请一个授权码,然后再用该码获取令牌。
这种方式是最常用的流程,安全性也最高,它适用于那些有后端的 Web 应用。授权码通过前端传送,令牌则是储存在后端,而且所有与资源服务器的通信都在后端完成。这样的前后端分离,可以避免令牌泄漏。
> https://b.com/oauth/authorize?> response_type=code& #要求返回授权码(code)> client_id=CLIENT_ID& #让 B 知道是谁在请求 > redirect_uri=CALLBACK_URL& #B 接受或拒绝请求后的跳转网址 > scope=read # 要求的授权范围(这里是只读) >
客户端申请授权的URI,包含以下参数:response_type:表示授权类型,必选项,此处的值固定为\"code\"client_id:表示客户端的ID,必选项redirect_uri:表示重定向URI,可选项scope:表示申请的权限范围,可选项state:表示客户端的当前状态,可以指定任意值,授权服务器会原封不动地返回这个值。
1. A网站提供一个链接,用户点击后就会跳转到 B 网站,授权用户数据给 A 网站使用。下面就是 A 网站跳转 B 网站的一个示意链接。
> https://a.com/callback?code=AUTHORIZATION_CODE #code参数就是授权码
2. 用户跳转后,B 网站会要求用户登录,然后询问是否同意给予 A 网站授权。用户表示同意,这时 B 网站就会跳回redirect_uri参数指定的网址。跳转时,会传回一个授权码,就像下面这样。
3. A 网站拿到授权码以后,就可以在后端,向 B 网站请求令牌。 用户不可见,服务端行为
> { > \"access_token\":\"ACCESS_TOKEN\
4. B 网站收到请求以后,就会颁发令牌。具体做法是向redirect_uri指定的网址,发送一段 JSON 数据。
有些 Web 应用是纯前端应用,没有后端。这时就不能用上面的方式了,必须将令牌储存在前端。RFC 6749 就规定了第二种方式,允许直接向前端颁发令牌,这种方式没有授权码这个中间步骤,所以称为(授权码)\"隐藏式\"(implicit)
简化模式不通过第三方应用程序的服务器,直接在浏览器中向授权服务器申请令牌,跳过了\"授权码\"这个步骤,所有步骤在浏览器中完成,令牌对访问者是可见的,且客户端不需要认证。这种方式把令牌直接传给前端,是很不安全的。因此,只能用于一些安全要求不高的场景,并且令牌的有效期必须非常短,通常就是会话期间(session)有效,浏览器关掉,令牌就失效了。
它的步骤如下:(A)客户端将用户导向授权服务器。(B)用户决定是否给于客户端授权。(C)假设用户给予授权,授权服务器将用户导向客户端指定的\"重定向URI\",并在URI的Hash部分包含了访问令牌。(D)浏览器向资源服务器发出请求,其中不包括上一步收到的Hash值。(E)资源服务器返回一个网页,其中包含的代码可以获取Hash值中的令牌。(F)浏览器执行上一步获得的脚本,提取出令牌。(G)浏览器将令牌发给客户端。
> https://b.com/oauth/authorize?> response_type=token& # response_type参数为token,表示要求直接返回令牌> client_id=CLIENT_ID&> redirect_uri=CALLBACK_URL&> scope=read>
1. A 网站提供一个链接,要求用户跳转到 B 网站,授权用户数据给 A 网站使用。
> https://a.com/callback#token=ACCESS_TOKEN #token参数就是令牌,A 网站直接在前端拿到令牌。>
2. 用户跳转到 B 网站,登录后同意给予 A 网站授权。这时,B 网站就会跳回redirect_uri参数指定的跳转网址,并且把令牌作为 URL 参数,传给 A 网站。
简化(隐式)模式
如果你高度信任某个应用,RFC 6749 也允许用户把用户名和密码,直接告诉该应用。该应用就使用你的密码,申请令牌,这种方式称为\"密码式\"(password)。在这种模式中,用户必须把自己的密码给客户端,但是客户端不得储存密码。这通常用在用户对客户端高度信任的情况下,比如客户端是操作系统的一部分,或者由一个著名公司出品。而授权服务器只有在其他授权模式无法执行的情况下,才能考虑使用这种模式。
在这种模式中,用户必须把自己的密码给客户端,但是客户端不得储存密码。这通常用在用户对客户端高度信任的情况下,比如客户端是操作系统的一部分,或者由一个著名公司出品。而授权服务器只有在其他授权模式无法执行的情况下,才能考虑使用这种模式。
适用场景:自家公司搭建的授权服务器
它的步骤如下:(A)用户向客户端提供用户名和密码。(B)客户端将用户名和密码发给授权服务器,向后者请求令牌。(C)授权服务器确认无误后,向客户端提供访问令牌。
> https://oauth.b.com/token?> grant_type=password& # 授权方式是\"密码式\"> username=USERNAME&> password=PASSWORD&> client_id=CLIENT_ID> client_secret=client_secret
1. A 网站要求用户提供 B 网站的用户名和密码,拿到以后,A 就直接向 B 请求令牌。整个过程中,客户端不得保存用户的密码。
2. B 网站验证身份通过后,直接给出令牌。注意,这时不需要跳转,而是把令牌放在 JSON 数据里面,作为 HTTP 回应,A 因此拿到令牌。
客户端模式(Client Credentials Grant)指客户端以自己的名义,而不是以用户的名义,向\"服务提供商\"进行授权。适用于没有前端的命令行应用,即在命令行下请求令牌。一般用来提供给我们完全信任的服务器端服务。
它的步骤如下:(A)客户端向授权服务器进行身份认证,并要求一个访问令牌。(B)授权服务器确认无误后,向客户端提供访问令牌。
> https://oauth.b.com/token?> grant_type=client_credentials&> client_id=CLIENT_ID&> client_secret=CLIENT_SECRET
1. A 应用在命令行向 B 发出请求。
2. B 网站验证通过以后,直接返回令牌。
客户端模式
2.1 客户端授权模式
A 网站拿到令牌以后,就可以向 B 网站的 API 请求数据了。
> curl -H \"Authorization: Bearer ACCESS_TOKEN\" \\> \"https://api.b.com\">
此时,每个发到 API 的请求,都必须带有令牌。具体做法是在请求的头信息,加上一个Authorization字段,令牌就放在这个字段里面。
也可以通过添加请求参数access_token请求数据。
2.2 令牌的使用
令牌的有效期到了,如果让用户重新走一遍上面的流程,再申请一个新的令牌,很可能体验不好,而且也没有必要。OAuth 2.0 允许用户自动更新令牌。
> https://b.com/oauth/token?> grant_type=refresh_token& # grant_type参数为refresh_token表示要求更新令牌> client_id=CLIENT_ID&> client_secret=CLIENT_SECRET&> refresh_token=REFRESH_TOKEN # 用于更新令牌的令牌>
具体方法是,B 网站颁发令牌的时候,一次性颁发两个令牌,一个用于获取数据,另一个用于获取新的令牌(refresh token 字段)。令牌到期前,用户使用 refresh token 发一个请求,去更新令牌。
2.3 更新令牌
2. OAuth2的设计思路
认证(Authentication) :用户认证就是判断一个用户的身份是否合法的过程,用户去访问系统资源时系统要求验证用户的身份信息,身份合法方可继续访问,不合法则拒绝访问。常见的用户身份认证方式有:用户名密码登录,二维码登录,手机短信登录,指纹认证等方式。 授权(Authorization): 授权是用户认证通过根据用户的权限来控制用户访问资源的过程,拥有资源的访问权限则正常访问,没有权限则拒绝访问。
Spring Security是一个能够为基于Spring的企业应用系统提供声明式的安全访问控制解决方案的安全框架。Spring Security 主要实现了Authentication(认证,解决who are you? ) 和 Access Control(访问控制,也就是what are you allowed to do?,也称为Authorization)。Spring Security在架构上将认证与授权分离,并提供了扩展点。
将OAuth2和Spring Security集成,就可以得到一套完整的安全解决方案。我们可以通过Spring Security OAuth2构建一个授权服务器来验证用户身份以提供access_token,并使用这个access_token来从资源服务器请求数据。
Authorization Endpoint :授权端点,进行授权Token Endpoint :令牌端点,经过授权拿到对应的TokenIntrospection Endpoint :校验端点,校验Token的合法性Revocation Endpoint :撤销端点,撤销授权
3.1 授权服务器
3.2 整体架构
引入依赖<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId></dependency><dependency> <groupId>org.springframework.security.oauth</groupId> <artifactId>spring-security-oauth2</artifactId> <version>2.3.4.RELEASE</version></dependency>或者 引入spring cloud oauth2依赖<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-oauth2</artifactId></dependency><!-- spring cloud --><dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>Hoxton.SR8</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies></dependencyManagement>
@Configuration@EnableAuthorizationServerpublic class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { @Autowired private PasswordEncoder passwordEncoder; @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.inMemory() //配置client_id .withClient(\"client\") //配置client-secret .secret(passwordEncoder.encode(\"123123\")) //配置访问token的有效期 .accessTokenValiditySeconds(3600) //配置刷新token的有效期 .refreshTokenValiditySeconds(864000) //配置redirect_uri,用于授权成功后跳转 .redirectUris(\"http://www.baidu.com\") //配置申请的权限范围 .scopes(\"all\") //配置grant_type,表示授权类型 .authorizedGrantTypes(\"authorization_code\"); }}配置资源服务器
配置授权服务器
@Configuration@EnableResourceServerpublic class ResourceServiceConfig extends ResourceServerConfigurerAdapter { @Override public void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .anyRequest().authenticated() .and().requestMatchers().antMatchers(\"/user/**\"); }}
配置资源服务器
选择Approve,点击授权获取授权码
测试获取授权码http://localhost:8080/oauth/authorize?response_type=code&client_id=client或者http://localhost:8080/oauth/authorize?response_type=code&client_id=client&redirect_uri=http://www.baidu.com&scope=all登录之后进入
根据授权码通过post请求获取
获取令牌
访问资源
3.3 授权码模式
authorizedGrantType添加implicit
测试http://localhost:8080/oauth/authorize?client_id=client&response_type=token&scope=all&redirect_uri=http://www.baidu.com登录之后进入授权页面,确定授权后浏览器会重定向到指定路径,并以Hash的形式存放在重定向uri的fargment中:
3.4 简化模式
@Configurationpublic class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } @Override protected void configure(HttpSecurity http) throws Exception { http.formLogin().permitAll() .and().authorizeRequests() .antMatchers(\"/oauth/**\").permitAll() .anyRequest().authenticated() .and().logout().permitAll() .and().csrf().disable(); } @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); }}
修改WebSecurityConfig,增加AuthenticationManager
修改AuthorizationServerConfig配置
通过浏览器测试,需要配置支持get请求和表单验证http://localhost:8080/oauth/token?username=fox&password=123456&grant_type=password&client_id=client&client_secret=123123&scope=all
3.5 密码模式
使用oauth2时,如果令牌失效了,可以使用刷新令牌通过refresh_token的授权模式再次获取access_token。只需修改认证服务器的配置,添加refresh_token的授权模式即可。
修改授权服务器配置,增加refresh_token配置
http://localhost:8080/oauth/token?grant_type=refresh_token&client_id=client&client_secret=123123&refresh_token=dc03bdc2-ca3b-4690-9265-d31a21896d02
通过密码模式测试
3.7 更新令牌
引入依赖<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId></dependency><dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId></dependency>
修改application.yamlspring: redis: host: 127.0.0.1 database: 0
编写redis配置类@Configurationpublic class RedisConfig { @Autowired private RedisConnectionFactory redisConnectionFactory; @Bean public TokenStore tokenStore(){ return new RedisTokenStore(redisConnectionFactory); }}
在授权服务器配置中指定令牌的存储策略为Redis
3.8 基于redis存储Token
3. Spring Security OAuth2快速开始
单点登录(Single Sign On),简称为 SSO,是比较流行的企业业务整合的解决方案之一。它的用途在于,不管多么复杂的应用群,只要在用户权限范围内,那么就可以做到,用户只需要登录一次就可以访问权限范围内的所有应用子系统。
1.1 什么是单点登录
适用场景:都是企业自己的系统,所有系统都使用同一个一级域名通过不同的二级域名来区分
核心原理:门户系统设置 Cookie 的 domain 为一级域名也就是 zlt.com,这样就可以共享门户的 Cookie 给所有的使用该域名(xxx.zlt.com)的系统使用 Spring Session 等技术让所有系统共享 Session这样只要门户系统登录之后无论跳转应用1或者应用2,都能通过门户 Cookie 中的 sessionId 读取到 Session 中的登录信息实现单点登录
同域单点登录
单点登录之间的系统域名不一样,例如第三方系统。由于域名不一样不能共享 Cookie 了,这样就需要通过一个单独的授权服务(UAA)来做统一登录,并基于共享UAA的 Cookie 来实现单点登录。
核心原理:访问系统1判断未登录,则跳转到UAA系统请求授权在UAA系统域名 sso.com 下的登录地址中输入用户名/密码完成登录登录成功后UAA系统把登录信息保存到 Session 中,并在浏览器写入域为 sso.com 的 Cookie访问系统2判断未登录,则跳转到UAA系统请求授权由于是跳转到UAA系统的域名 sso.com 下,所以能通过浏览器中UAA的 Cookie 读取到 Session 中之前的登录信息完成单点登录
跨域单点登录
基于Oauth2跨域单点登录
1.2 单点登录常见实现方式
Oauth2单点登录除了需要授权中心完成统一登录/授权逻辑之外,各个系统本身(sso客户端)也需要实现以下逻辑:拦截请求判断登录状态与 UAA授权中心 通过 Oauth2授权码模式 交互完成登录/单点登录保存用户登录信息以上逻辑只需使用一个 @EnableOAuth2Sso 注解即可实现
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-oauth2</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency></dependencies>
server: port: 8081 #防止Cookie冲突,冲突会导致登录验证不通过 servlet: session: cookie: name: OAUTH2-CLIENT-SESSIONID${server.port}#与授权服务器对应的配置security: oauth2: client: client-id: client client-secret: 123123 user-authorization-uri: http://localhost:8080/oauth/authorize access-token-uri: http://localhost:8080/oauth/token resource: token-info-uri: http://localhost:8080/oauth/check_token
2)修改application.properties
@EnableOAuth2Sso单点登录的原理简单来说就是:标注有@EnableOAuth2Sso的OAuth2 Client应用在通过某种OAuth2授权流程获取访问令牌后(一般是授权码流程),通过访问令牌访问userDetails用户明细这个受保护资源服务,获取用户信息后,将用户信息转换为Spring Security上下文中的认证后凭证Authentication,从而完成标注有@EnableOAuth2Sso的OAuth2 Client应用自身的登录认证的过程。整个过程是基于OAuth2的SSO单点登录@SpringBootApplication
3)在启动类上添加@EnableOAuth2Sso注解来启用单点登录功能
@RestController@RequestMapping(\"/user\")public class UserController { @RequestMapping(\"/getCurrentUser\") public Object getCurrentUser(Authentication authentication) { return authentication; }}
4)添加接口用于获取当前登录用户信息
创建客户端
@Configuration@EnableAuthorizationServerpublic class AuthorizationServerConfig3 extends AuthorizationServerConfigurerAdapter { @Autowired private PasswordEncoder passwordEncoder; @Override public void configure(AuthorizationServerSecurityConfigurer security) throws Exception { //允许表单认证 security.allowFormAuthenticationForClients(); //校验token security.checkTokenAccess(\"permitAll()\"); } @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { /** *授权码模式 */ clients.inMemory() //配置client_id .withClient(\"client\") //配置client-secret .secret(passwordEncoder.encode(\"123123\")) //配置访问token的有效期 .accessTokenValiditySeconds(3600) //配置刷新token的有效期 .refreshTokenValiditySeconds(864000) //配置redirect_uri,用于授权成功后跳转 .redirectUris(\"http://localhost:8081/login\
1)修改授权服务器中的AuthorizationServerConfig类
测试: 访问客户端需要授权的接口http://localhost:8081/user/getCurrentUser会跳转到授权服务的登录界面
2)启动授权服务和客户端服务
修改application.properties配置server.port=8082#防止Cookie冲突,冲突会导致登录验证不通过server.servlet.session.cookie.name=OAUTH2-CLIENT-SESSIONID${server.port}
修改授权服务器配置,配置多个跳转路径//配置redirect_uri,用于授权成功后跳转.redirectUris(\"http://localhost:8081/login\
8081登录成功之后,8082无需再次登录就可以访问http://localhost:8082/user/getCurrentUser
3)模拟两个客户端8081,8082
创建授权服务器
.3 Spring Secuirty Oauth2单点登录实战
核心代码,网关自定义全局过滤器进行身份认证
1.4 Oauth2整合网关实现微服务单点登录
1. Spring Secuirty Oauth2实现单点登录
OAuth 2.0是当前业界标准的授权协议,它的核心是若干个针对不同场景的令牌颁发和管理流程;而JWT是一种轻量级、自包含的令牌,可用于在微服务间安全地传递用户信息。
JSON Web Token(JWT)是一个开放的行业标准(RFC 7519),它定义了一种简介的、自包含的协议格式,用于在通信双方传递json对象,传递的信息经过数字签名可以被验证和信任。JWT可以使用HMAC算法或使用RSA的公钥/私钥对来签名,防止被篡改。官网: https://jwt.io/标准: https://tools.ietf.org/html/rfc7519
JWT令牌的优点:jwt基于json,非常方便解析。可以在令牌中自定义丰富的内容,易扩展。通过非对称加密算法及数字签名技术,JWT防止篡改,安全性高。资源服务使用JWT可不依赖授权服务即可完成授权。缺点: JWT令牌较长,占存储空间比较大。
2.1 什么是JWT
一个JWT实际上就是一个字符串,它由三部分组成,头部(header)、载荷(payload)与签名(signature)。
头部用于描述关于该JWT的最基本的信息:类型(即JWT)以及签名所用的算法(如HMACSHA256或RSA)等。这也可以被表示成一个JSON对象:{ \"alg\": \"HS256\
头部(header)
jwt的第三部分是一个签证信息,这个签证信息由三部分组成:header (base64后的)payload (base64后的)secret(盐,一定要保密)
这个部分需要base64加密后的header和base64加密后的payload使用.连接组成的字符串,然后通过header中声明的加密方式进行加盐secret组合加密,然后就构成了jwt的第三部分:
签名(signature)
2.2 JWT组成
如何应用
JJWT是一个提供端到端的JWT创建和验证的Java库,永远免费和开源(Apache License,版本2.0)。JJW很容易使用和理解。它被设计成一个以建筑为中心的流畅界面,隐藏了它的大部分复杂性。
引入依赖 <!--JWT依赖--><dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version></dependency>
快速开始
创建测试类,生成token@Testpublic void test() { //创建一个JwtBuilder对象 JwtBuilder jwtBuilder = Jwts.builder() //声明的标识{\"jti\":\"666\"} .setId(\"666\") //主体,用户{\"sub\":\"Fox\"} .setSubject(\"Fox\") //创建日期{\"ita\":\"xxxxxx\
创建token
@Testpublic void testParseToken(){ //token String token =\"eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiI2NjYiLCJzdWIiOiJGb3giLCJpYXQiOjE2MDgyNzI1NDh9\" + \".Hz7tk6pJaest_jxFrJ4BWiMg3HQxjwY9cGmJ4GQwfuU\"; //解析token获取载荷中的声明对象 Claims claims = Jwts.parser() .setSigningKey(\"123123\") .parseClaimsJws(token) .getBody(); System.out.println(\"id:\"+claims.getId()); System.out.println(\"subject:\"+claims.getSubject()); System.out.println(\"issuedAt:\"+claims.getIssuedAt());}
token的验证解析
有很多时候,我们并不希望签发的token是永久生效的,所以我们可以为token添加一个过期时间。原因:从服务器发出的token,服务器自己并不做记录,就存在一个弊端:服务端无法主动控制某个token的立刻失效。
当未过期时可以正常读取,当过期时会引发io.jsonwebtoken.ExpiredJwtException异常。
token过期校验
@Testpublic void test() { //创建一个JwtBuilder对象 JwtBuilder jwtBuilder = Jwts.builder() //声明的标识{\"jti\":\"666\"} .setId(\"666\") //主体,用户{\"sub\":\"Fox\"} .setSubject(\"Fox\") //创建日期{\"ita\":\"xxxxxx\"} .setIssuedAt(new Date()) //设置过期时间 1分钟 .setExpiration(new Date(System.currentTimeMillis()+60*1000)) //直接传入map // .addClaims(map) .claim(\"roles\
我们刚才的例子只是存储了id和subject两个信息,如果你想存储更多的信息(例如角色)可以自定义claims。
自定义claims
2.3 JJWT
在之前的spring security Oauth2的代码基础上修改引入依赖<dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-jwt</artifactId> <version>1.0.9.RELEASE</version></dependency>
@Configurationpublic class JwtTokenStoreConfig { @Bean public TokenStore jwtTokenStore(){ return new JwtTokenStore(jwtAccessTokenConverter()); } @Bean public JwtAccessTokenConverter jwtAccessTokenConverter(){ JwtAccessTokenConverter accessTokenConverter = new JwtAccessTokenConverter(); //配置JWT使用的秘钥 accessTokenConverter.setSigningKey(\"123123\"); return accessTokenConverter; }}
添加配置文件JwtTokenStoreConfig.java
在授权服务器配置中指定令牌的存储策略为JWT
整合JWT
有时候我们需要扩展JWT中存储的内容,这里我们在JWT中扩展一个 key为enhance,value为enhance info 的数据。继承TokenEnhancer实现一个JWT内容增强器
@Beanpublic JwtTokenEnhancer jwtTokenEnhancer() {return new JwtTokenEnhancer();}
创建一个JwtTokenEnhancer实例
在授权服务器配置中配置JWT的内容增强器
扩展JWT中的存储内容
添加依赖 <!--JWT依赖--><dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version></dependency>
@GetMapping(\"/getCurrentUser\
修改UserController类,使用jjwt工具类来解析Authorization头中存储的JWT内容
解析JWT
2.4 Spring Security Oauth2整合JWT
2. JWT
Oauth
skywalking是一个国产开源框架,2015年由吴晟开源 , 2017年加入Apache孵化器。skywalking是分布式系统的应用程序性能监视工具,专为微服务、云原生架构和基于容器(Docker、K8s、Mesos)架构而设计。SkyWalking 是观察性分析平台和应用性能管理系统,提供分布式追踪、服务网格遥测分析、度量聚合和可视化一体化解决方案。官网:http://skywalking.apache.org/下载:http://skywalking.apache.org/downloads/Github:https://github.com/apache/skywalking文档:https://skywalking.apache.org/docs/main/v9.1.0/readme/中文文档: https://skyapm.github.io/document-cn-translation-of-skywalking/版本: v9.1.0采集数据——》传输数据——》存储数据——》分析数据——》监控报警
1、多种监控手段,可以通过语言探针和service mesh获得监控的数据;2、支持多种语言自动探针,包括 Java,.NET Core 和 Node.JS;3、轻量高效,无需大数据平台和大量的服务器资源;4、模块化,UI、存储、集群管理都有多种机制可选;5、支持告警;6、优秀的可视化解决方案;
1.1 Skywalking主要功能特性
整个架构分成四部分:上部分Agent :负责从应用中,收集链路信息,发送给 SkyWalking OAP 服务器;下部分 SkyWalking OAP :负责接收Agent发送的Tracing数据信息,然后进行分析(Analysis Core),存储到外部存储器(Storage),最终提供查询(Query)功能;右部分Storage:Tracing数据存储,目前支持ES、MySQL、Sharding Sphere、TiDB、H2多种存储器,目前采用较多的是ES,主要考虑是SkyWalking开发团队自己的生产环境采用ES为主;左部分SkyWalking UI:负责提供控制台,查看链路等等;
● Agent – 基于ByteBuddy字节码增强技术实现,通过jvm的agent参数加载,并在程序启动时拦截指定的方法来收集数据。● SDK – 程序中显式调用SkyWalking提供的SDK来收集数据,对应用有侵入。● Service Mesh – 通过Service mesh的网络代理来收集数据。
SkyWalking支持三种探针:
后端(Backend)接受探针发送过来的数据,进行度量分析,调用链分析和存储。后端主要分为两部分:● OAP(Observability Analysis Platform)- 进行度量分析和调用链分析的后端平台,并支持将数据存储到各种数据库中,如:ElasticSearch,MySQL,InfluxDB等。● OAL(Observability Analysis Language)- 用来进行度量分析的DSL,类似于SQL,用于查询度量分析结果和警报。
界面(UI)● RocketBot UI – SkyWalking 7.0.0 的默认web UI● CLI – 命令行界面
这三个模块的交互流程:
1.2 Skywalking整体架构
skywalking agent和业务系统绑定在一起,负责收集各种监控数据Skywalking oapservice是负责处理监控数据的,比如接受skywalking agent的监控数据,并存储在数据库中;接受skywalking webapp的前端请求,从数据库查询数据,并返回数据给前端。Skywalking oapservice通常以集群的形式存在。skywalking webapp,前端界面,用于展示数据。用于存储监控数据的数据库,比如mysql、elasticsearch等。
SkyWalking APM: v9.1.0wget https://archive.apache.org/dist/skywalking/9.1.0/apache-skywalking-apm-9.1.0.tar.gzJava Agent: v8.11.0 wget https://archive.apache.org/dist/skywalking/java-agent/8.11.0/apache-skywalking-java-agent-8.11.0.tgz
目录结构
下载 SkyWalking 下载:http://skywalking.apache.org/downloads/
config/application.yml
日志信息存储在logs目录
启动成功后会启动两个服务,一个是skywalking-oap-server,一个是skywalking-web-uiskywalking-oap-server服务启动后会暴露11800 和 12800 两个端口,分别为收集监控数据的端口11800和接受前端请求的端口12800,修改端口可以修改config/applicaiton.yml
skywalking-web-ui服务会占用 8080 端口, 修改端口可以修改webapp/webapp.yml
server.port:SkyWalking UI服务端口,默认是8080;spring.cloud.discovery.client.simple.instances.oap-service:SkyWalking OAP服务地址数组,SkyWalking UI界面的数据是通过请求SkyWalking OAP服务来获得;
2)启动脚本bin/startup.sh
搭建SkyWalking OAP 服务
SkyWalking中三个概念
1.3 SkyWalking 环境搭建部署
1. skywalking是什么
准备一个springboot程序,打成可执行jar包,写一个shell脚本,在启动项目的Shell脚本上,通过 -javaagent 参数进行配置SkyWalking Agent来追踪微服务;
等同于java -javaagent:/root/skywalking-agent/skywalking-agent.jar -DSW_AGENT_COLLECTOR_BACKEND_SERVICES=127.0.0.1:11800 -DSW_AGENT_NAME=springboot-skywalking-demo -jar springboot-skywalking-demo-0.0.1-SNAPSHOT.jar
参数名对应agent/config/agent.config配置文件中的属性。属性对应的源码:org.apache.skywalking.apm.agent.core.conf.Config.java# The service name in UIagent.service_name=${SW_AGENT_NAME:Your_ApplicationName}# Backend service addresses.collector.backend_service=${SW_AGENT_COLLECTOR_BACKEND_SERVICES:127.0.0.1:11800}
我们也可以使用skywalking.+配置文件中的配置名作为系统配置项来进行覆盖。 javaagent参数配置方式优先级更高-javaagent:/root/skywalking-agent/skywalking-agent.jar-Dskywalking.agent.service_name=springboot-skywalking-demo-Dskywalking.collector.backend_service=127.0.0.1:11800
2.1.1 通过jar包方式接入
在运行的程序配置jvm参数-javaagent:D:\\apache\\apache-skywalking-java-agent-8.11.0\\skywalking-agent\\skywalking-agent.jar-DSW_AGENT_NAME=springboot-skywalking-demo-DSW_AGENT_COLLECTOR_BACKEND_SERVICES=192.168.65.206:11800
2.1.2 在IDEA中使用Skywalking
Skywalking跨多个微服务追踪,只需要每个微服务启动时添加javaagent参数即可。启动微服务mall-gateway,mall-order,mall-user ,配置skywalking的jvm参数
注意:此处存在bug,追踪链路不显示gateway
解决方案:拷贝agent/optional-plugins目录下的gateway插件和webflux插件到agent/plugins目录
2.1.3 Skywalking跨多个微服务追踪
2.1 SkyWalking Agent追踪微服务
https://skywalking.apache.org/docs/skywalking-java/latest/en/setup/service-agent/java-agent/application-toolkit-logback-1.x/引入依赖<!-- apm-toolkit-logback-1.x --><dependency> <groupId>org.apache.skywalking</groupId> <artifactId>apm-toolkit-logback-1.x</artifactId> <version>8.11.0</version></dependency>
<?xml version=\"1.0\" encoding=\"UTF-8\"?><configuration> <appender name=\"console\" class=\"ch.qos.logback.core.ConsoleAppender\"> <!-- 日志的格式化 --> <encoder class=\"ch.qos.logback.core.encoder.LayoutWrappingEncoder\"> <layout class=\"org.apache.skywalking.apm.toolkit.log.logback.v1.x.TraceIdPatternLogbackLayout\"> <Pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%tid] [%thread] %-5level %logger{36} -%msg%n</Pattern> </layout> </encoder> </appender> <!-- 设置 Appender --> <root level=\"INFO\"> <appender-ref ref=\"console\"/> </root></configuration>
微服务添加logback-spring.xml文件,并配置 %tid 占位符
gRPC报告程序可以将收集到的日志转发到SkyWalking OAP服务器上
logback-spring.xml中添加 <!-- https://skywalking.apache.org/docs/skywalking-java/latest/en/setup/service-agent/java-agent/application-toolkit-logback-1.x/ --><!-- 通过grpc上报日志到skywalking oap--><appender name=\"grpc-log\" class=\"org.apache.skywalking.apm.toolkit.log.logback.v1.x.log.GRPCLogClientAppender\"> <encoder class=\"ch.qos.logback.core.encoder.LayoutWrappingEncoder\"> <layout class=\"org.apache.skywalking.apm.toolkit.log.logback.v1.x.TraceIdPatternLogbackLayout\"> <Pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%tid] [%thread] %-5level %logger{36} -%msg%n</Pattern> </layout> </encoder></appender>
Skywalking通过grpc上报日志 (需要v8.4.0以上)
2.2 Skywalking集成日志框架
skywalking告警的核心由一组规则驱动,这些规则定义在config/alarm-settings.yml文件中,告警规则的定义分为三部分:告警规则:它们定义了应该如何触发度量警报,应该考虑什么条件;网络钩子(Webhook}:当警告触发时,哪些服务终端需要被通知;gRPC钩子:远程gRPC方法的主机和端口,告警触发后调用;
为了方便,skywalking发行版中提供了默认的alarm-setting.yml文件,包括一些规则,每个规则有英文注释,可以根据注释得知每个规则的作用:在最近10分钟的3分钟内服务平均响应时间超过1000ms最近10分钟内,服务成功率在2分钟内低于80%服务实例的响应时间在过去10分钟的2分钟内超过1000ms数据库访问{name}的响应时间在过去10分钟的2分钟内超过1000ms
只要我们的服务请求符合alarm-setting.yml文件中的某一条规则就会触发告警。比如service_resp_time_rule规则:该规则表示服务{name}的响应时间在最近10分钟的3分钟内超过1000ms
在config/alarm-settings.yml中配置回调接口,并重启skywalking服务
实现回调接口
对接钉钉:
Webhook回调通知
2.3 Skywalking告警通知
1. 修改config目录下的application.yml,使用mysql作为持久化存储的仓库
storage: #选择使用mysql 默认使用h2,不会持久化,重启skyWalking之前的数据会丢失 selector: ${SW_STORAGE:mysql} #使用mysql作为持久化存储的仓库 mysql: properties: #数据库连接地址 创建swtest数据库 jdbcUrl: ${SW_JDBC_URL:\"jdbc:mysql://1ocalhost:3306/swtest\"} #用户名 dataSource.user: ${SW_DATA_SOURCE_USER:root} #密码 dataSource.password: ${SW_DATA_SOURCE_PASSWORD:root}
注意:需要添加mysql数据驱动包,因为在lib目录下是没有mysql数据驱动包的,所以修改完配置启动是会报错,启动失败的。
2. 修改mysql连接配置
3. 添加mysql数据驱动包到oap-libs目录下
4. 启动Skywalking
查看swtest数据库,可以看到生成了很多表。
2.4.1 基于mysql持久化
启动elasticsearch服务bin/elasticsearch -d
1.准备好elasticsearch环境(参考ES专题)
2.修改config/application.yml配置文件,指定存储使用ES,修改elasticsearch的连接配置
3. 启动Skywalking服务
启动时会向elasticsearch中创建大量的index索引用于持久化数据启动应用程序,查看追踪数据是否已经持久化到elasticsearch的索引中,然后重启skywalking,验证追踪数据会不会丢失
2.4.2 基于elasticsearch持久化
2.4 Skywalking持久化追踪数据
如果我们希望对项目中的业务方法,实现链路追踪,方便我们排查问题,可以使用如下的代码引入依赖<!-- SkyWalking 工具类 --><dependency> <groupId>org.apache.skywalking</groupId> <artifactId>apm-toolkit-trace</artifactId> <version>8.11.0</version></dependency>
在业务方法中可以TraceContext获取到traceId@RequestMapping(\"/list\")public List<User> list(){ //TraceContext可以绑定key-value TraceContext.putCorrelation(\"name\
在Skywalking UI中查询tranceId
如果一个业务方法想在ui界面的追踪链路上显示出来,只需要在业务方法上加上@Trace注解即可
2.5.1 @Trace将方法加入追踪链路
@Trace@Tag(key = \"list\
我们还可以为追踪链路增加其他额外的信息,比如记录参数和返回信息。实现方式:在方法上增加@Tag或者@Tags。
2.5.2 加入@Tags或@Tag
2.5 自定义SkyWalking链路追踪
Skywalking集群是将skywalking oap作为一个服务注册到nacos上,只要skywalking oap服务没有全部宕机,保证有一个skywalking oap在运行,就能进行追踪。
搭建一个skywalking oap集群需要:(1)至少一个Nacos(也可以是nacos集群)(2)至少一个ElasticSearch(也可以是es集群)(3)至少2个skywalking oap服务;(4)至少1个UI(UI也可以集群多个,用Nginx代理统一入口)
使用nacos作为注册中心
修改nacos配置
可以选择性修改监听端口
修改存储策略,使用elasticsearch作为storage
1.修改config/application.yml文件
2. 配置ui服务webapp.yml文件的oap-service,写多个oap服务地址
2.6 Skywalking集群部署(oap服务高可用)
2. SkyWalking快速开始
skywalking
自动化监控系统Prometheus&Grafana实战:https://vip.tulingxueyuan.cn/detail/v_60f96e69e4b0e6c3a312c726/3?from=p_6006cac4e4b00ff4ed156218&type=8&parent_pro_id=p_6006d8c8e4b00ff4ed1569b2
APM-性能监控项目班:https://vip.tulingxueyuan.cn/detail/p_602e574ae4b035d3cdb8f8fe/6
监控
BeanFactoryPostProcessor BeanDefinitionRegistryPostProcessorBeanPostProcessor InstantiationAwareBeanPostProcessor AbstractAutoProxyCreator@Import ImportBeanDefinitionRegistrar ImportSelectorAware ApplicationContextAware BeanFactoryAwareInitializingBean || @PostConstructFactoryBeanSmartInitializingSingletonApplicationListenerLifecycle SmartLifecycle LifecycleProcessorHandlerInterceptorMethodInterceptor
Bean生命周期主线流程:https://www.processon.com/view/link/5eafa609f346fb177ba8091f
1. Spring扩展点梳理
ApplicationListener扩展场景——监听容器中发布的事件
思考: 为什么整合Nacos注册中心后,服务启动就会自动注册,Nacos是如何实现自动服务注册的?
NacosAutoServiceRegistration
Nacos注册中心源码分析https://www.processon.com/view/link/5ea27ca15653bb6efc68eb8c
扩展: Eureka Server端上下文的初始化是在SmartLifecycle#start中实现的
#对SmartLifecycle的扩展NacosWatch#start#订阅服务接收实例更改的事件》NamingService#subscribe
NacosWatch
EurekaServerInitializerConfiguration
Eureka Server源码分析:https://www.processon.com/view/link/5e5fa095e4b0a967bb35b667
Lifecycle扩展场景——管理具有启动、停止生命周期需求的对象
2.1 整合Nacos
思考:为什么@Bean修饰的RestTemplate加上@LoadBalanced就能实现负载均衡功能?
@Bean@LoadBalancedpublic RestTemplate restTemplate() { return new RestTemplate();}
对SmartInitializingSingleton的扩展,为所有用@LoadBalanced修饰的restTemplate(利用了@Qualifier)绑定实现了负载均衡逻辑的拦截器LoadBalancerInterceptor
LoadBalancerInterceptor
LoadBalancerAutoConfiguration
SmartInitializingSingleton扩展场景—— 对容器中的Bean对象进行定制处理
https://www.processon.com/view/link/5e7466dce4b027d999bdaddb
2.2 整合Ribbon
思考:为什么Feign接口可以通过@Autowired直接注入使用?Feign接口是如何交给Spring管理的?
FactoryBean的扩展场景——将接口生成的代理对象交给Spring管理
FeignClientsRegistrar
FeignClientFactorybean
https://www.processon.com/view/link/5e80ae79e4b03b99653fe42f
2.3 整合Feign
# Webmvc接口资源保护入口AbstractSentinelInterceptor#preHandle
AbstractSentinelInterceptor
HandlerInterceptor扩展场景——对mvc请求增强
SentinelDataSourceHandler
NacosDataSourceFactoryBean
SmartInitializingSingleton&FactoryBean结合场景——根据类型动态装配对象
https://www.processon.com/view/link/607fef267d9c08283ddc2f8d
2.4 整合sentinel
AbstractAutoProxyCreator&MethodInterceptor结合场景——实现方法增强
https://www.processon.com/view/link/5f743063e0b34d0711f001d2
2.5 整合seata
2. Spring扩展点应用场景
扩展点
SpringCloud
String 应用场景•单值缓存SET key value GET key •对象缓存1) SET user:1 value(json格式数据)2) MSET user:1:name zhuge user:1:balance 1888 MGET user:1:name user:1:balance •分布式锁SETNX product:10001 true //返回1代表获取锁成功SETNX product:10001 true //返回0代表获取锁失败。。。执行业务操作DEL product:10001 //执行完业务释放锁SET product:10001 true ex 10 nx //防止程序意外终止导致死锁•计数器INCR article:readcount:{文章id} GET article:readcount:{文章id} •Web集群session共享spring session + redis实现session共享••分布式系统全局序列号 INCRBY orderId 1000 //redis批量生成序列号提升性能
•字符串常用操作SET key value //存入字符串键值对MSET key value [key value ...] //批量存储字符串键值对SETNX key value //存入一个不存在的字符串键值对GET key //获取一个字符串键值MGET key [key ...] //批量获取字符串键值DEL key [key ...] //删除一个键EXPIRE key seconds //设置一个键的过期时间(秒)•原子加减INCR key //将key中储存的数字值加1DECR key //将key中储存的数字值减1INCRBY key increment //将key所储存的值加上incrementDECRBY key decrement //将key所储存的值减去decrement
•对象缓存HMSET user {userId}:name zhuge {userId}:balance 1888HMSET user 1:name zhuge 1:balance 1888HMGET user 1:name 1:balance
•电商购物车1)以用户id为key2)商品id为field3)商品数量为value•购物车操作1)添加商品àhset cart:1001 10088 12)增加数量àhincrby cart:1001 10088 13)商品总数àhlen cart:10014)删除商品àhdel cart:1001 100885)获取购物车所有商品àhgetall cart:1001
•优点1)同类数据归类整合储存,方便数据管理2)相比string操作消耗内存与cpu更小3)相比string储存更节省空间•缺点1)过期功能不能使用在field上,只能用在key上2)Redis集群架构下不适合大规模使用
•Hash常用操作HSET key field value //存储一个哈希表key的键值HSETNX key field value //存储一个不存在的哈希表key的键值HMSET key field value [field value ...] //在一个哈希表key中存储多个键值对HGET key field //获取哈希表key对应的field键值HMGET key field [field ...] //批量获取哈希表key中多个field键值HDEL key field [field ...] //删除哈希表key中的field键值HLEN key //返回哈希表key中field的数量HGETALL key //返回哈希表key中所有的键值HINCRBY key field increment //为哈希表key中field键的值加上增量increment
常用数据结构Stack(栈) = LPUSH + LPOPQueue(队列)= LPUSH + RPOPBlocking MQ(阻塞队列)= LPUSH + BRPOP
微博消息和微信公号消息诸葛老师关注了MacTalk,备胎说车等大V1)MacTalk发微博,消息ID为10018LPUSH msg:{诸葛老师-ID} 100182)备胎说车发微博,消息ID为10086LPUSH msg:{诸葛老师-ID} 100863)查看最新微博消息LRANGE msg:{诸葛老师-ID} 0 4
微信抽奖小程序1)点击参与抽奖加入集合SADD key {userlD}2)查看参与抽奖所有用户SMEMBERS key 3)抽取count名中奖者SRANDMEMBER key [count] / SPOP key [count]
微信微博点赞,收藏,标签1) 点赞SADD like:{消息ID} {用户ID}2) 取消点赞SREM like:{消息ID} {用户ID}3) 检查用户是否点过赞SISMEMBER like:{消息ID} {用户ID}4) 获取点赞的用户列表SMEMBERS like:{消息ID}5) 获取点赞用户数 SCARD like:{消息ID}
集合操作实现电商商品筛选SADD brand:huawei P40SADD brand:xiaomi mi-10SADD brand:iPhone iphone12SADD os:android P40 mi-10SADD cpu:brand:intel P40 mi-10SADD ram:8G P40 mi-10 iphone12SINTER os:android cpu:brand:intel ram:8G -> {P40,mi-10}
Set常用操作SADD key member [member ...] //往集合key中存入元素,元素存在则忽略, 若key不存在则新建SREM key member [member ...] //从集合key中删除元素SMEMBERS key //获取集合key中所有元素SCARD key //获取集合key的元素个数SISMEMBER key member //判断member元素是否存在于集合key中SRANDMEMBER key [count] //从集合key中选出count个元素,元素不从key中删除SPOP key [count] //从集合key中选出count个元素,元素从key中删除Set运算操作SINTER key [key ...] //交集运算SINTERSTORE destination key [key ..] //将交集结果存入新集合destination中SUNION key [key ..] //并集运算SUNIONSTORE destination key [key ...] //将并集结果存入新集合destination中SDIFF key [key ...] //差集运算SDIFFSTORE destination key [key ...] //将差集结果存入新集合destination中
Zset集合操作实现排行榜1)点击新闻ZINCRBY hotNews:20190819 1 守护香港2)展示当日排行前十ZREVRANGE hotNews:20190819 0 9 WITHSCORES 3)七日搜索榜单计算ZUNIONSTORE hotNews:20190813-20190819 7 hotNews:20190813 hotNews:20190814... hotNews:201908194)展示七日排行前十ZREVRANGE hotNews:20190813-20190819 0 9 WITHSCORES
ZSet有序集合结构ZSet常用操作ZADD key score member [[score member]…] //往有序集合key中加入带分值元素ZREM key member [member …] //从有序集合key中删除元素ZSCORE key member //返回有序集合key中元素member的分值ZINCRBY key increment member //为有序集合key中元素member的分值加上increment ZCARD key //返回有序集合key中元素个数ZRANGE key start stop [WITHSCORES] //正序获取有序集合key从start下标到stop下标的元素ZREVRANGE key start stop [WITHSCORES] //倒序获取有序集合key从start下标到stop下标的元素Zset集合操作ZUNIONSTORE destkey numkeys key [key ...] //并集计算ZINTERSTORE destkey numkeys key [key …] //交集计算
五种数据结构
Redis集群架构
数据结构和高性能原理
save与bgsave对比:命令 save bgsave IO类型 同步 异步是否阻塞redis其它命令 是 否(在生成子进程执行调用fork函数时会有短暂阻塞)复杂度 O(n) O(n)优点 不会消耗额外内存 不阻塞客户端命令缺点 阻塞客户端命令 需要fork子进程,消耗内存配置自动生成rdb文件后台使用的是bgsave方式。
RDB快照(snapshot)在默认情况下, Redis 将内存数据库快照保存在名字为 dump.rdb 的二进制文件中。你可以对 Redis 进行设置, 让它在“ N 秒内数据集至少有 M 个改动”这一条件被满足时, 自动保存一次数据集。比如说, 以下设置会让 Redis 在满足“ 60 秒内有至少有 1000 个键被改动”这一条件时, 自动保存一次数据集:# save 60 1000 //关闭RDB只需要将所有的save保存策略注释掉即可还可以手动执行命令生成RDB快照,进入redis客户端执行命令save或bgsave可以生成dump.rdb文件,每次命令执行都会将所有redis内存快照到一个新的rdb文件里,并覆盖原有rdb快照文件。
比如执行命令“set zhuge 666”,aof文件里会记录如下数据*3$3set$5zhuge$3666这是一种resp协议格式数据,星号后面的数字代表命令有多少个参数,$号后面的数字代表这个参数有几个字符
注意,如果执行带过期时间的set命令,aof文件里记录的是并不是执行的原始命令,而是记录key过期的时间戳比如执行“set tuling 888 ex 1000”,对应aof文件里记录如下*3$3set$6tuling$3888*3$9PEXPIREAT$6tuling$131604249786301
有三个选项:appendfsync always:每次有新命令追加到 AOF 文件时就执行一次 fsync ,非常慢,也非常安全。appendfsync everysec:每秒 fsync 一次,足够快,并且在故障时只会丢失 1 秒钟的数据。appendfsync no:从不 fsync ,将数据交给操作系统来处理。更快,也更不安全的选择。推荐(并且也是默认)的措施为每秒 fsync 一次, 这种 fsync 策略可以兼顾速度和安全性。
你可以通过修改配置文件来打开 AOF 功能:# appendonly yes从现在开始, 每当 Redis 执行一个改变数据集的命令时(比如 SET), 这个命令就会被追加到 AOF 文件的末尾。这样的话, 当 Redis 重新启动时, 程序就可以通过重新执行 AOF 文件中的命令来达到重建数据集的目的。你可以配置 Redis 多久才将数据 fsync 到磁盘一次。
如下两个配置可以控制AOF自动重写频率# auto-aof-rewrite-min-size 64mb //aof文件至少要达到64M才会自动重写,文件太小恢复速度本来就很快,重写的意义不大# auto-aof-rewrite-percentage 100 //aof文件自上一次重写后文件大小增长了100%则再次触发重写当然AOF还可以手动重写,进入redis客户端执行命令bgrewriteaof重写AOF注意,AOF重写redis会fork出一个子进程去做(与bgsave命令类似),不会对redis正常命令处理有太多影响
AOF重写AOF文件里可能有太多没用指令,所以AOF会定期根据内存的最新数据生成aof文件例如,执行了如下几条命令:127.0.0.1:6379> incr readcount(integer) 1127.0.0.1:6379> incr readcount(integer) 2127.0.0.1:6379> incr readcount(integer) 3127.0.0.1:6379> incr readcount(integer) 4127.0.0.1:6379> incr readcount(integer) 5重写后AOF文件里变成*3$3SET$2readcount$15
AOF(append-only file)快照功能并不是非常耐久(durable): 如果 Redis 因为某些原因而造成故障停机, 那么服务器将丢失最近写入、且仍未保存到快照中的那些数据。从 1.1 版本开始, Redis 增加了一种完全耐久的持久化方式: AOF 持久化,将修改的每一条指令记录进文件appendonly.aof中(先写入os cache,每隔一段时间fsync到磁盘)
RDB 和 AOF ,我应该用哪一个?命令 RDB AOF启动优先级 低 高体积 小 大恢复速度 快 慢数据安全性 容易丢数据 根据策略决定生产环境可以都启用,redis启动时如果既有rdb文件又有aof文件则优先选择aof文件恢复数据,因为aof一般来说数据更全一点。
通过如下配置可以开启混合持久化(必须先开启aof):# aof-use-rdb-preamble yes
如果开启了混合持久化,AOF在重写时,不再是单纯将内存数据转换为RESP命令写入AOF文件,而是将重写这一刻之前的内存做RDB快照处理,并且将RDB快照内容和增量的AOF修改内存数据的命令存在一起,都写入新的AOF文件,新的文件一开始不叫appendonly.aof,等到重写完新的AOF文件才会进行改名,覆盖原有的AOF文件,完成新旧两个AOF文件的替换。于是在 Redis 重启的时候,可以先加载 RDB 的内容,然后再重放增量 AOF 日志就可以完全替代之前的 AOF 全量文件重放,因此重启效率大幅得到提升。
混合持久化AOF文件结构如下
Redis数据备份策略:写crontab定时调度脚本,每小时都copy一份rdb或aof的备份到一个目录中去,仅仅保留最近48小时的备份每天都保留一份当日的数据备份到一个目录中去,可以保留最近1个月的备份每次copy备份的时候,都把太旧的备份给删了每天晚上将当前机器上的备份复制一份到其他机器上,以防机器损坏
Redis 4.0 混合持久化重启 Redis 时,我们很少使用 RDB来恢复内存状态,因为会丢失大量数据。我们通常使用 AOF 日志重放,但是重放 AOF 日志性能相对 RDB来说要慢很多,这样在 Redis 实例很大的情况下,启动需要花费很长的时间。 Redis 4.0 为了解决这个问题,带来了一个新的持久化选项——混合持久化。
Redis持久化
1、复制一份redis.conf文件2、将相关配置修改为如下值:port 6380pidfile /var/run/redis_6380.pid # 把pid进程号写入pidfile配置的文件logfile \"6380.log\"dir /usr/local/redis-5.0.3/data/6380 # 指定数据存放目录# 需要注释掉bind# bind 127.0.0.1(bind绑定的是自己机器网卡的ip,如果有多块网卡可以配多个ip,代表允许客户端通过机器的哪些网卡ip去访问,内网一般可以不配置bind,注释掉即可)3、配置主从复制replicaof 192.168.0.60 6379 # 从本机6379的redis实例复制数据,Redis 5.0之前使用slaveofreplica-read-only yes # 配置从节点只读4、启动从节点redis-server redis.conf # redis.conf文件务必用你复制并修改了之后的redis.conf文件5、连接从节点redis-cli -p 63806、测试在6379实例上写数据,6380实例是否能及时同步新修改数据7、可以自己再配置一个6381的从节点
redis主从架构搭建,配置从节点步骤:
如果你为master配置了一个slave,不管这个slave是否是第一次连接上Master,它都会发送一个PSYNC命令给master请求复制数据。master收到PSYNC命令后,会在后台进行数据持久化通过bgsave生成最新的rdb快照文件,持久化期间,master会继续接收客户端的请求,它会把这些可能修改数据集的请求缓存在内存中。当持久化进行完毕以后,master会把这份rdb文件数据集发送给slave,slave会把接收到的数据进行持久化生成rdb,然后再加载到内存中。然后,master再将之前缓存在内存中的命令发送给slave。当master与slave之间的连接由于某些原因而断开时,slave能够自动重连Master,如果master收到了多个slave并发连接请求,它只会进行一次持久化,而不是一个连接一次,然后再把这一份持久化的数据发送给多个并发连接的slave。
主从复制(全量复制)流程图:
如果有很多从节点,为了缓解主从复制风暴(多个从节点同时复制主节点导致主节点压力过大),可以做如下架构,让部分从节点与从节点(与主节点同步)同步数据
数据部分复制当master和slave断开重连后,一般都会对整份数据进行复制。但从redis2.8版本开始,redis改用可以支持部分数据复制的命令PSYNC去master同步数据,slave与master能够在网络连接断开重连后只进行部分数据复制(断点续传)。master会在其内存中创建一个复制数据用的缓存队列,缓存最近一段时间的数据,master和它所有的slave都维护了复制的数据下标offset和master的进程id,因此,当网络连接断开后,slave会请求master继续进行未完成的复制,从所记录的数据下标开始。如果master进程id变化了,或者从节点数据下标offset太旧,已经不在master的缓存队列里了,那么将会进行一次全量数据的复制。主从复制(部分复制,断点续传)流程图:
Redis主从工作原理
Redis主从架构
1、复制一份sentinel.conf文件cp sentinel.conf sentinel-26379.conf2、将相关配置修改为如下值:port 26379daemonize yespidfile \"/var/run/redis-sentinel-26379.pid\"logfile \"26379.log\"dir \"/usr/local/redis-5.0.3/data\"# sentinel monitor <master-redis-name> <master-redis-ip> <master-redis-port> <quorum># quorum是一个数字,指明当有多少个sentinel认为一个master失效时(值一般为:sentinel总数/2 + 1),master才算真正失效sentinel monitor mymaster 192.168.0.60 6379 2 # mymaster这个名字随便取,客户端访问时会用到3、启动sentinel哨兵实例src/redis-sentinel sentinel-26379.conf4、查看sentinel的info信息src/redis-cli -p 26379127.0.0.1:26379>info可以看到Sentinel的info里已经识别出了redis的主从5、可以自己再配置两个sentinel,端口26380和26381,注意上述配置文件里的对应数字都要修改
sentinel集群都启动完毕后,会将哨兵集群的元数据信息写入所有sentinel的配置文件里去(追加在文件的最下面),我们查看下如下配置文件sentinel-26379.conf,如下所示:sentinel known-replica mymaster 192.168.0.60 6380 #代表redis主节点的从节点信息sentinel known-replica mymaster 192.168.0.60 6381 #代表redis主节点的从节点信息sentinel known-sentinel mymaster 192.168.0.60 26380 52d0a5d70c1f90475b4fc03b6ce7c3c56935760f #代表感知到的其它哨兵节点sentinel known-sentinel mymaster 192.168.0.60 26381 e9f530d3882f8043f76ebb8e1686438ba8bd5ca6 #代表感知到的其它
当redis主节点如果挂了,哨兵集群会重新选举出新的redis主节点,同时会修改所有sentinel节点配置文件的集群元数据信息,比如6379的redis如果挂了,假设选举出的新主节点是6380,则sentinel文件里的集群元数据信息会变成如下所示:sentinel known-replica mymaster 192.168.0.60 6379 #代表主节点的从节点信息sentinel known-replica mymaster 192.168.0.60 6381 #代表主节点的从节点信息sentinel known-sentinel mymaster 192.168.0.60 26380 52d0a5d70c1f90475b4fc03b6ce7c3c56935760f #代表感知到的其它哨兵节点sentinel known-sentinel mymaster 192.168.0.60 26381 e9f530d3882f8043f76ebb8e1686438ba8bd5ca6 #代表感知到的其它哨兵节点同时还会修改sentinel文件里之前配置的mymaster对应的6379端口,改为6380sentinel monitor mymaster 192.168.0.60 6380 2当6379的redis实例再次启动时,哨兵集群根据集群元数据信息就可以将6379端口的redis节点作为从节点加入集群
redis哨兵架构搭建步骤:
哨兵leader选举流程当一个master服务器被某sentinel视为下线状态后,该sentinel会与其他sentinel协商选出sentinel的leader进行故障转移工作。每个发现master服务器进入下线的sentinel都可以要求其他sentinel选自己为sentinel的leader,选举是先到先得。同时每个sentinel每次选举都会自增配置纪元(选举周期),每个纪元中只会选择一个sentinel的leader。如果所有超过一半的sentinel选举某sentinel作为leader。之后该sentinel进行故障转移操作,从存活的slave中选举出新的master,这个选举过程跟集群的master选举很类似。哨兵集群只有一个哨兵节点,redis的主从也能正常运行以及选举master,如果master挂了,那唯一的那个哨兵节点就是哨兵leader了,可以正常选举新master。不过为了高可用一般都推荐至少部署三个哨兵节点。为什么推荐奇数个哨兵节点原理跟集群奇数个master节点类似。
sentinel哨兵是特殊的redis服务,不提供读写服务,主要用来监控redis实例节点。哨兵架构下client端第一次从哨兵找出redis的主节点,后续就直接访问redis的主节点,不会每次都通过sentinel代理访问redis的主节点,当redis的主节点发生变化,哨兵会第一时间感知到,并且将新的redis主节点通知给client端(这里面redis的client端一般都实现了订阅功能,订阅sentinel发布的节点变动消息)
启动整个集群/usr/local/redis-5.0.3/src/redis-server /usr/local/redis-cluster/8001/redis.conf/usr/local/redis-5.0.3/src/redis-server /usr/local/redis-cluster/8002/redis.conf/usr/local/redis-5.0.3/src/redis-server /usr/local/redis-cluster/8003/redis.conf/usr/local/redis-5.0.3/src/redis-server /usr/local/redis-cluster/8004/redis.conf/usr/local/redis-5.0.3/src/redis-server /usr/local/redis-cluster/8005/redis.conf/usr/local/redis-5.0.3/src/redis-server /usr/local/redis-cluster/8006/redis.conf
# 客户端连接8001端口的redis实例/usr/local/redis-5.0.3/src/redis-cli -a zhuge -c -h 192.168.0.61 -p 8001
# 查看集群状态192.168.0.61:8001> cluster nodes从上图可以看出,整个集群运行正常,三个master节点和三个slave节点,8001端口的实例节点存储0-5460这些hash槽,8002端口的实例节点存储5461-10922这些hash槽,8003端口的实例节点存储10923-16383这些hash槽,这三个master节点存储的所有hash槽组成redis集群的存储槽位,slave点是每个主节点的备份从节点,不显示存储槽位
启动集群
增加redis实例# 在/usr/local/redis-cluster下创建8007和8008文件夹,并拷贝8001文件夹下的redis.conf文件到8007和8008这两个文件夹下mkdir 8007 8008cd 8001cp redis.conf /usr/local/redis-cluster/8007/cp redis.conf /usr/local/redis-cluster/8008/# 修改8007文件夹下的redis.conf配置文件vim /usr/local/redis-cluster/8007/redis.conf# 修改如下内容:port:8007dir /usr/local/redis-cluster/8007/cluster-config-file nodes-8007.conf# 修改8008文件夹下的redis.conf配置文件vim /usr/local/redis-cluster/8008/redis.conf修改内容如下:port:8008dir /usr/local/redis-cluster/8008/cluster-config-file nodes-8008.conf# 启动8007和8008俩个服务并查看服务状态/usr/local/redis-5.0.3/src/redis-server /usr/local/redis-cluster/8007/redis.conf/usr/local/redis-5.0.3/src/redis-server /usr/local/redis-cluster/8008/redis.confps -el | grep redis
查看redis集群的命令帮助cd /usr/local/redis-5.0.3src/redis-cli --cluster help1.create:创建一个集群环境host1:port1 ... hostN:portN2.call:可以执行redis命令3.add-node:将一个节点添加到集群里,第一个参数为新节点的ip:port,第二个参数为集群中任意一个已经存在的节点的ip:port 4.del-node:移除一个节点5.reshard:重新分片6.check:检查集群状态
配置8007为集群主节点# 使用add-node命令新增一个主节点8007(master),前面的ip:port为新增节点,后面的ip:port为已知存在节点,看到日志最后有\"[OK] New node added correctly\"提示代表新节点加入成功/usr/local/redis-5.0.3/src/redis-cli -a zhuge --cluster add-node 192.168.0.61:8007 192.168.0.61:8001
# 查看集群状态/usr/local/redis-5.0.3/src/redis-cli -a zhuge -c -h 192.168.0.61 -p 8001192.168.0.61:8001> cluster nodes注意:当添加节点成功以后,新增的节点不会有任何数据,因为它还没有分配任何的slot(hash槽),我们需要为新节点手工分配hash槽
# 查看下最新的集群状态/usr/local/redis-5.0.3/src/redis-cli -a zhuge -c -h 192.168.0.61 -p 8001192.168.0.61:8001> cluster nodes如上图所示,现在我们的8007已经有hash槽了,也就是说可以在8007上进行读写数据啦!到此为止我们的8007已经加入到集群中,并且是主节点(Master)
配置8008为8007的从节点# 添加从节点8008到集群中去并查看集群状态/usr/local/redis-5.0.3/src/redis-cli -a zhuge --cluster add-node 192.168.0.61:8008 192.168.0.61:8001如图所示,还是一个master节点,没有被分配任何的hash槽。
# 查看集群状态,8008节点已成功添加为8007节点的从节点
删除8008从节点# 用del-node删除从节点8008,指定删除节点ip和端口,以及节点id(红色为8008节点id)/usr/local/redis-5.0.3/src/redis-cli -a zhuge --cluster del-node 192.168.0.61:8008 a1cfe35722d151cf70585cee21275565393c0956
# 再次查看集群状态,如下图所示,8008这个slave节点已经移除,并且该节点的redis服务也已被停止
删除8007主节点最后,我们尝试删除之前加入的主节点8007,这个步骤相对比较麻烦一些,因为主节点的里面是有分配了hash槽的,所以我们这里必须先把8007里的hash槽放入到其他的可用主节点中去,然后再进行移除节点操作,不然会出现数据丢失问题(目前只能把master的数据迁移到一个节点上,暂时做不了平均分配功能),执行命令如下:/usr/local/redis-5.0.3/src/redis-cli -a zhuge --cluster reshard 192.168.0.61:8007输出如下: ... ...How many slots do you want to move (from 1 to 16384)? 600What is the receiving node ID? dfca1388f124dec92f394a7cc85cf98cfa02f86f(ps:这里是需要把数据移动到哪?8001的主节点id)Please enter all the source node IDs. Type 'all' to use all the nodes as source nodes for the hash slots. Type 'done' once you entered all the source nodes IDs.Source node 1:2728a594a0498e98e4b83a537e19f9a0a3790f38(ps:这里是需要数据源,也就是我们的8007节点id)Source node 2:done(ps:这里直接输入done 开始生成迁移计划) ... ...Do you want to proceed with the proposed reshard plan (yes/no)? Yes(ps:这里输入yes开始迁移) 至此,我们已经成功的把8007主节点的数据迁移到8001上去了,我们可以看一下现在的集群状态如下图,你会发现8007下面已经没有任何hash槽了,证明迁移成功!
# 最后我们直接使用del-node命令删除8007主节点即可/usr/local/redis-5.0.3/src/redis-cli -a zhuge --cluster del-node 192.168.0.61:8007 2728a594a0498e98e4b83a537e19f9a0a3790f38
我们在原始集群基础上再增加一主(8007)一从(8008),增加节点后的集群参见下图,新增节点用虚线框表示
集群操作
第一步:在第一台机器的/usr/local下创建文件夹redis-cluster,然后在其下面分别创建2个文件夾如下(1)mkdir -p /usr/local/redis-cluster(2)mkdir 8001 8004第一步:把之前的redis.conf配置文件copy到8001下,修改如下内容:(1)daemonize yes(2)port 8001(分别对每个机器的端口号进行设置)(3)pidfile /var/run/redis_8001.pid # 把pid进程号写入pidfile配置的文件(4)dir /usr/local/redis-cluster/8001/(指定数据文件存放位置,必须要指定不同的目录位置,不然会丢失数据)(5)cluster-enabled yes(启动集群模式)(6)cluster-config-file nodes-8001.conf(集群节点信息文件,这里800x最好和port对应上)(7)cluster-node-timeout 10000 (8)# bind 127.0.0.1(bind绑定的是自己机器网卡的ip,如果有多块网卡可以配多个ip,代表允许客户端通过机器的哪些网卡ip去访问,内网一般可以不配置bind,注释掉即可) (9)protected-mode no (关闭保护模式) (10)appendonly yes如果要设置密码需要增加如下配置: (11)requirepass zhuge (设置redis访问密码) (12)masterauth zhuge (设置集群节点间访问密码,跟上面一致)第三步:把修改后的配置文件,copy到8004,修改第2、3、4、6项里的端口号,可以用批量替换::%s/源字符串/目的字符串/g 第四步:另外两台机器也需要做上面几步操作,第二台机器用8002和8005,第三台机器用8003和8006第五步:分别启动6个redis实例,然后检查是否启动成功(1)/usr/local/redis-5.0.3/src/redis-server /usr/local/redis-cluster/800*/redis.conf(2)ps -ef | grep redis 查看是否启动成功 第六步:用redis-cli创建整个redis集群(redis5以前的版本集群是依靠ruby脚本redis-trib.rb实现)# 下面命令里的1代表为每个创建的主服务器节点创建一个从服务器节点# 执行这条命令需要确认三台机器之间的redis实例要能相互访问,可以先简单把所有机器防火墙关掉,如果不关闭防火墙则需要打开redis服务端口和集群节点gossip通信端口16379(默认是在redis端口号上加1W)# 关闭防火墙# systemctl stop firewalld # 临时关闭防火墙# systemctl disable firewalld # 禁止开机启动# 注意:下面这条创建集群的命令大家不要直接复制,里面的空格编码可能有问题导致创建集群不成功(1)/usr/local/redis-5.0.3/src/redis-cli -a zhuge --cluster create --cluster-replicas 1 192.168.0.61:8001 192.168.0.62:8002 192.168.0.63:8003 192.168.0.61:8004 192.168.0.62:8005 192.168.0.63:8006 第七步:验证集群:(1)连接任意一个客户端即可:./redis-cli -c -h -p (-a访问服务端密码,-c表示集群模式,指定ip地址和端口号) 如:/usr/local/redis-5.0.3/src/redis-cli -a zhuge -c -h 192.168.0.61 -p 800*(2)进行验证: cluster info(查看集群信息)、cluster nodes(查看节点列表)(3)进行数据操作验证(4)关闭集群则需要逐个进行关闭,使用命令:/usr/local/redis-5.0.3/src/redis-cli -a zhuge -c -h 192.168.0.60 -p 800* shutdown
redis集群搭建 redis集群需要至少三个master节点,我们这里搭建三个master节点,并且给每个master再搭建一个slave节点,总共6个redis节点,这里用三台机器部署6个redis实例,每台机器一主一从,搭建集群的步骤如下:
Redis3.0以后的版本虽然有了集群功能,提供了比之前版本的哨兵模式更高的性能与可用性,但是集群的水平扩展却比较麻烦,今天就来带大家看看redis高可用集群如何做水平扩展,原始集群(见下图)由6个节点组成,6个节点分布在三台机器上,采用三主三从的模式
槽位定位算法Cluster 默认会对 key 值使用 crc16 算法进行 hash 得到一个整数值,然后用这个整数值对 16384 进行取模来得到具体槽位。HASH_SLOT = CRC16(key) mod 16384
跳转重定位当客户端向一个错误的节点发出了指令,该节点会发现指令的 key 所在的槽位并不归自己管理,这时它会向客户端发送一个特殊的跳转指令携带目标操作的节点地址,告诉客户端去连这个节点去获取数据。客户端收到指令后除了跳转到正确的节点上去操作,还会同步更新纠正本地的槽位映射表缓存,后续所有 key 将使用新的槽位映射表。
集中式: 优点在于元数据的更新和读取,时效性非常好,一旦元数据出现变更立即就会更新到集中式的存储中,其他节点读取的时候立即就可以立即感知到;不足在于所有的元数据的更新压力全部集中在一个地方,可能导致元数据的存储压力。 很多中间件都会借助zookeeper集中式存储元数据。
gossip通信的10000端口 每个节点都有一个专门用于节点间gossip通信的端口,就是自己提供服务的端口号+10000,比如7001,那么用于节点间通信的就是17001端口。 每个节点每隔一段时间都会往另外几个节点发送ping消息,同时其他几点接收到ping消息之后返回pong消息。
gossip: gossip协议包含多种消息,包括ping,pong,meet,fail等等。 meet:某个节点发送meet给新加入的节点,让新节点加入集群中,然后新节点就会开始与其他节点进行通信;ping:每个节点都会频繁给其他节点发送ping,其中包含自己的状态还有自己维护的集群元数据,互相通过ping交换元数据(类似自己感知到的集群节点增加和移除,hash slot信息等); pong: 对ping和meet消息的返回,包含自己的状态和其他信息,也可以用于信息广播和更新; fail: 某个节点判断另一个节点fail之后,就发送fail给其他节点,通知其他节点,指定的节点宕机了。gossip协议的优点在于元数据的更新比较分散,不是集中在一个地方,更新请求会陆陆续续,打到所有节点上去更新,有一定的延时,降低了压力;缺点在于元数据更新有延时可能导致集群的一些操作会有一些滞后。
Redis集群节点间的通信机制redis cluster节点间采取gossip协议进行通信 维护集群的元数据(集群节点信息,主从角色,节点数量,各节点共享的数据等)有两种方式:集中式和gossip
网络抖动真实世界的机房网络往往并不是风平浪静的,它们经常会发生各种各样的小问题。比如网络抖动就是非常常见的一种现象,突然之间部分连接变得不可访问,然后很快又恢复正常。为解决这种问题,Redis Cluster 提供了一种选项cluster-node-timeout,表示当某个节点持续 timeout 的时间失联时,才可以认定该节点出现故障,需要进行主从切换。如果没有这个选项,网络抖动会导致主从频繁切换 (数据的重新复制)。
Redis集群选举原理分析当slave发现自己的master变为FAIL状态时,便尝试进行Failover,以期成为新的master。由于挂掉的master可能会有多个slave,从而存在多个slave竞争成为master节点的过程, 其过程如下:1.slave发现自己的master变为FAIL2.将自己记录的集群currentEpoch加1,并广播FAILOVER_AUTH_REQUEST 信息3.其他节点收到该信息,只有master响应,判断请求者的合法性,并发送FAILOVER_AUTH_ACK,对每一个epoch只发送一次ack4.尝试failover的slave收集master返回的FAILOVER_AUTH_ACK5.slave收到超过半数master的ack后变成新Master(这里解释了集群为什么至少需要三个主节点,如果只有两个,当其中一个挂了,只剩一个主节点是不能选举成功的)6.slave广播Pong消息通知其他集群节点。从节点并不是在主节点一进入 FAIL 状态就马上尝试发起选举,而是有一定延迟,一定的延迟确保我们等待FAIL状态在集群中传播,slave如果立即尝试选举,其它masters或许尚未意识到FAIL状态,可能会拒绝投票•延迟计算公式: DELAY = 500ms + random(0 ~ 500ms) + SLAVE_RANK * 1000ms•SLAVE_RANK表示此slave已经从master复制数据的总量的rank。Rank越小代表已复制的数据越新。这种方式下,持有最新数据的slave将会首先发起选举(理论上)。
集群脑裂数据丢失问题redis集群没有过半机制会有脑裂问题,网络分区导致脑裂后多个主节点对外提供写服务,一旦网络分区恢复,会将其中一个主节点变为从节点,这时会有大量数据丢失。规避方法可以在redis配置里加上参数(这种方法不可能百分百避免数据丢失,参考集群leader选举机制):min-replicas-to-write 1 //写数据成功最少同步的slave数量,这个数量可以模仿大于半数机制配置,比如集群总共三个节点可以配置1,加上leader就是2,超过了半数注意:这个配置在一定程度上会影响集群的可用性,比如slave要是少于1个,这个集群就算leader正常也不能提供服务了,需要具体场景权衡选择。
集群是否完整才能对外提供服务当redis.conf的配置cluster-require-full-coverage为no时,表示当负责一个插槽的主库下线且没有相应的从库进行故障恢复时,集群仍然可用,如果为yes则集群不可用。
Redis集群为什么至少需要三个master节点,并且推荐节点数为奇数?因为新master的选举需要大于半数的集群master节点同意才能选举成功,如果只有两个master节点,当其中一个挂了,是达不到选举新master的条件的。 奇数个master节点可以在满足选举该条件的基础上节省一个节点,比如三个master节点和四个master节点的集群相比,大家如果都挂了一个master节点都能选举新master节点,如果都挂了两个master节点都没法选举新master节点了,所以奇数的master节点更多的是从节省机器资源角度出发说的。
Redis集群对批量操作命令的支持对于类似mset,mget这样的多个key的原生批量操作命令,redis集群只支持所有key落在同一slot的情况,如果有多个key一定要用mset命令在redis集群上操作,则可以在key的前面加上{XX},这样参数数据分片hash计算的只会是大括号里的值,这样能确保不同的key能落到同一slot里去,示例如下:mset {user1}:1:name zhuge {user1}:1:age 18假设name和age计算的hash slot值不一样,但是这条命令在集群下执行,redis只会用大括号里的 user1 做hash slot计算,所以算出来的slot值肯定相同,最后都能落在同一slot。
Redis集群原理分析Redis Cluster 将所有数据划分为 16384 个 slots(槽位),每个节点负责其中一部分槽位。槽位的信息存储于每个节点中。当 Redis Cluster 的客户端来连接集群时,它也会得到一份集群的槽位配置信息并将其缓存在客户端本地。这样当客户端要查找某个 key 时,可以直接定位到目标节点。同时因为槽位的信息可能会存在客户端与服务器不一致的情况,还需要纠正机制来实现槽位信息的校验调整。
Reids集群模式
哨兵模式 在redis3.0以前的版本要实现集群一般是借助哨兵sentinel工具来监控master节点的状态,如果master节点异常,则会做主从切换,将某一台slave作为master,哨兵的配置略微复杂,并且性能和高可用性等各方面表现一般,特别是在主从切换的瞬间存在访问瞬断的情况,而且哨兵模式只有一个主节点对外提供服务,没法支持很高的并发,且单个主节点内存也不宜设置得过大,否则会导致持久化文件过大,影响数据恢复或主从同步的效率
高可用集群模式redis集群是一个由多个主从节点群组成的分布式服务器群,它具有复制、高可用和分片特性。Redis集群不需要sentinel哨兵·也能完成节点移除和故障转移的功能。需要将每个节点设置成集群模式,这种集群模式没有中心节点,可水平扩展,据官方文档称可以线性扩展到上万个节点(官方推荐不超过1000个节点)。redis集群的性能和高可用性均优于之前版本的哨兵模式,且集群配置非常简单
Redis集群方案比较
Redis高可用架构
多级缓存架构--------------------------------------------------------------------------------
可以用redisson实现布隆过滤器,引入依赖:<dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.6.5</version></dependency>
示例伪代码:package com.redisson;import org.redisson.Redisson;import org.redisson.api.RBloomFilter;import org.redisson.api.RedissonClient;import org.redisson.config.Config;public class RedissonBloomFilter { public static void main(String[] args) { Config config = new Config(); config.useSingleServer().setAddress(\"redis://localhost:6379\"); //构造Redisson RedissonClient redisson = Redisson.create(config); RBloomFilter<String> bloomFilter = redisson.getBloomFilter(\"nameList\
使用布隆过滤器需要把所有数据提前放入布隆过滤器,并且在增加数据时也要往布隆过滤器里放,布隆过滤器缓存过滤伪代码://初始化布隆过滤器RBloomFilter<String> bloomFilter = redisson.getBloomFilter(\"nameList\
2、布隆过滤器对于恶意攻击,向服务器请求大量不存在的数据造成的缓存穿透,还可以用布隆过滤器先做一次过滤,对于不存在的数据布隆过滤器一般都能够过滤掉,不让请求再往后端发送。当布隆过滤器说某个值存在时,这个值可能不存在;当它说不存在时,那就肯定不存在。布隆过滤器就是一个大型的位数组和几个不一样的无偏 hash 函数。所谓无偏就是能够把元素的 hash 值算得比较均匀。向布隆过滤器中添加 key 时,会使用多个 hash 函数对 key 进行 hash 算得一个整数索引值然后对位数组长度进行取模运算得到一个位置,每个 hash 函数都会算得一个不同的位置。再把位数组的这几个位置都置为 1 就完成了 add 操作。向布隆过滤器询问 key 是否存在时,跟 add 一样,也会把 hash 的几个位置都算出来,看看位数组中这几个位置是否都为 1,只要有一个位为 0,那么说明布隆过滤器中这个key 不存在。如果都是 1,这并不能说明这个 key 就一定存在,只是极有可能存在,因为这些位被置为 1 可能是因为其它的 key 存在所致。如果这个位数组长度比较大,存在概率就会很大,如果这个位数组长度比较小,存在概率就会降低。这种方法适用于数据命中不高、 数据相对固定、 实时性低(通常是数据集较大) 的应用场景, 代码维护较为复杂, 但是缓存空间占用很少。可以用redisson实现布隆过滤器,引入依赖:
sentinel自动黑名单
缓存穿透缓存穿透是指查询一个根本不存在的数据, 缓存层和存储层都不会命中, 通常出于容错的考虑, 如果从存储层查不到数据则不写入缓存层。缓存穿透将导致不存在的数据每次请求都要到存储层去查询, 失去了缓存保护后端存储的意义。造成缓存穿透的基本原因有两个:第一, 自身业务代码或者数据出现问题。第二, 一些恶意攻击、 爬虫等造成大量空命中。
hotKey 京东组件
多级缓存
缓存不过期
热点分散
缓存失效(击穿)由于大批量缓存在同一时间失效可能导致大量请求同时穿透缓存直达数据库,可能会造成数据库瞬间压力过大甚至挂掉,对于这种情况我们在批量增加缓存时最好将这一批数据的缓存过期时间设置为一个时间段内的不同时间。
1. 主从 可以进行恢复redis
2. 加入限流
3. 加入应用级缓存
缓存雪崩缓存雪崩指的是缓存层支撑不住或宕掉后, 流量会像奔逃的野牛一样, 打向后端存储层。由于缓存层承载着大量请求, 有效地保护了存储层, 但是如果缓存层由于某些原因不能提供服务(比如超大并发过来,缓存层支撑不住,或者由于缓存设计不好,类似大量请求访问bigkey,导致缓存能支撑的并发急剧下降), 于是大量请求都会打到存储层, 存储层的调用量会暴增, 造成存储层也会级联宕机的情况。 预防和解决缓存雪崩问题, 可以从以下三个方面进行着手。1) 保证缓存层服务高可用性,比如使用Redis Sentinel或Redis Cluster。2) 依赖隔离组件为后端限流熔断并降级。比如使用Sentinel或Hystrix限流降级组件。比如服务降级,我们可以针对不同的数据采取不同的处理方式。当业务应用访问的是非核心数据(例如电商商品属性,用户信息等)时,暂时停止从缓存中查询这些数据,而是直接返回预定义的默认降级信息、空值或是错误提示信息;当业务应用访问的是核心数据(例如电商商品库存)时,仍然允许查询缓存,如果缓存缺失,也可以继续通过数据库读取。3) 提前演练。 在项目上线前, 演练缓存层宕掉后, 应用以及后端的负载情况以及可能出现的问题, 在此基础上做一些预案设定。
示例伪代码:String get(String key) { // 从Redis中获取数据 String value = redis.get(key); // 如果value为空, 则开始重构缓存 if (value == null) { // 只允许一个线程重建缓存, 使用nx, 并设置过期时间ex String mutexKey = \"mutext:key:\
热点缓存key重建优化开发人员使用“缓存+过期时间”的策略既可以加速数据读写, 又保证数据的定期更新, 这种模式基本能够满足绝大部分需求。 但是有两个问题如果同时出现, 可能就会对应用造成致命的危害:当前key是一个热点key(例如一个热门的娱乐新闻),并发量非常大。重建缓存不能在短时间完成, 可能是一个复杂计算, 例如复杂的SQL、 多次IO、 多个依赖等。在缓存失效的瞬间, 有大量线程来重建缓存, 造成后端负载加大, 甚至可能会让应用崩溃。要解决这个问题主要就是要避免大量线程同时重建缓存。我们可以利用互斥锁来解决,此方法只允许一个线程重建缓存, 其他线程等待重建缓存的线程执行完, 重新从缓存获取数据即可。
1、双写不一致情况
2、读写并发不一致
解决方案:1、对于并发几率很小的数据(如个人维度的订单数据、用户数据等),这种几乎不用考虑这个问题,很少会发生缓存不一致,可以给缓存数据加上过期时间,每隔一段时间触发读的主动更新即可。2、就算并发很高,如果业务上能容忍短时间的缓存数据不一致(如商品名称,商品分类菜单等),缓存加上过期时间依然可以解决大部分业务对于缓存的要求。3、如果不能容忍缓存数据不一致,可以通过加分布式读写锁保证并发读写或写写的时候按顺序排好队,读读的时候相当于无锁。4、也可以用阿里开源的canal通过监听数据库的binlog日志及时的去修改缓存,但是引入了新的中间件,增加了系统的复杂度。
总结:以上我们针对的都是读多写少的情况加入缓存提高性能,如果写多读多的情况又不能容忍缓存数据不一致,那就没必要加缓存了,可以直接操作数据库。当然,如果数据库抗不住压力,还可以把缓存作为数据读写的主存储,异步将数据同步到数据库,数据库只是作为数据的备份。放入缓存的数据应该是对实时性、一致性要求不是很高的数据。切记不要为了用缓存,同时又要保证绝对的一致性做大量的过度设计和控制,增加系统复杂性!
缓存与数据库双写不一致在大并发下,同时操作数据库与缓存会存在数据不一致性问题
缓存设计--------------------------------------------------------------------------------
1. key名设计(1)【建议】: 可读性和可管理性以业务名(或数据库名)为前缀(防止key冲突),用冒号分隔,比如业务名:表名:idtrade:order:1
(2)【建议】:简洁性保证语义的前提下,控制key的长度,当key较多时,内存占用也不容忽视,例如:user:{uid}:friends:messages:{mid} 简化为 u:{uid}:fr:m:{mid}
(3)【强制】:不要包含特殊字符反例:包含空格、换行、单双引号以及其他转义字符
2. value设计(1)【强制】:拒绝bigkey(防止网卡流量、慢查询)在Redis中,一个字符串最大512MB,一个二级数据结构(例如hash、list、set、zset)可以存储大约40亿个(2^32-1)个元素,但实际中如果下面两种情况,我就会认为它是bigkey。字符串类型:它的big体现在单个value值很大,一般认为超过10KB就是bigkey。非字符串类型:哈希、列表、集合、有序集合,它们的big体现在元素个数太多。一般来说,string类型控制在10KB以内,hash、list、set、zset元素个数不要超过5000。反例:一个包含200万个元素的list。非字符串的bigkey,不要使用del删除,使用hscan、sscan、zscan方式渐进式删除,同时要注意防止bigkey过期时间自动删除问题(例如一个200万的zset设置1小时过期,会触发del操作,造成阻塞)
bigkey的产生:一般来说,bigkey的产生都是由于程序设计不当,或者对于数据规模预料不清楚造成的,来看几个例子:(1) 社交类:粉丝列表,如果某些明星或者大v不精心设计下,必是bigkey。(2) 统计类:例如按天存储某项功能或者网站的用户集合,除非没几个人用,否则必是bigkey。(3) 缓存类:将数据从数据库load出来序列化放到Redis里,这个方式非常常用,但有两个地方需要注意,第一,是不是有必要把所有字段都缓存;第二,有没有相关关联的数据,有的同学为了图方便把相关数据都存一个key下,产生bigkey。
如何优化bigkey1. 拆big list: list1、list2、...listNbig hash:可以讲数据分段存储,比如一个大的key,假设存了1百万的用户数据,可以拆分成200个key,每个key下面存放5000个用户数据2. 如果bigkey不可避免,也要思考一下要不要每次把所有元素都取出来(例如有时候仅仅需要hmget,而不是hgetall),删除也是一样,尽量使用优雅的方式来处理。(2)【推荐】:选择适合的数据类型。例如:实体类型(要合理控制和使用数据结构,但也要注意节省内存和性能之间的平衡)反例:set user:1:name tomset user:1:age 19set user:1:favor football正例:hmset user:1 name tom age 19 favor football3.【推荐】:控制key的生命周期,redis不是垃圾桶。建议使用expire设置过期时间(条件允许可以打散过期时间,防止集中过期)。
bigkey的危害:1.导致redis阻塞2.网络拥塞bigkey也就意味着每次获取要产生的网络流量较大,假设一个bigkey为1MB,客户端每秒访问量为1000,那么每秒产生1000MB的流量,对于普通的千兆网卡(按照字节算是128MB/s)的服务器来说简直是灭顶之灾,而且一般服务器会采用单机多实例的方式来部署,也就是说一个bigkey可能会对其他实例也造成影响,其后果不堪设想。3. 过期删除有个bigkey,它安分守己(只执行简单的命令,例如hget、lpop、zscore等),但它设置了过期时间,当它过期后,会被删除,如果没有使用Redis 4.0的过期异步删除(lazyfree-lazy-expire yes),就会存在阻塞Redis的可能性。
一、键值设计
1.【推荐】 O(N)命令关注N的数量例如hgetall、lrange、smembers、zrange、sinter等并非不能使用,但是需要明确N的值。有遍历的需求可以使用hscan、sscan、zscan代替。2.【推荐】:禁用命令禁止线上使用keys、flushall、flushdb等,通过redis的rename机制禁掉命令,或者使用scan的方式渐进式处理。3.【推荐】合理使用selectredis的多数据库较弱,使用数字进行区分,很多客户端支持较差,同时多业务用多数据库实际还是单线程处理,会有干扰。4.【推荐】使用批量操作提高效率原生命令:例如mget、mset。非原生命令:可以使用pipeline提高效率。但要注意控制一次批量操作的元素个数(例如500以内,实际也和元素字节数有关)。注意两者不同:1. 原生命令是原子操作,pipeline是非原子操作。2. pipeline可以打包不同的命令,原生命令做不到3. pipeline需要客户端和服务端同时支持。5.【建议】Redis事务功能较弱,不建议过多使用,可以用lua替代
二、命令使用
优化建议:1)maxTotal:最大连接数,早期的版本叫maxActive实际上这个是一个很难回答的问题,考虑的因素比较多:业务希望Redis并发量客户端执行命令时间Redis资源:例如 nodes(例如应用个数) * maxTotal 是不能超过redis的最大连接数maxclients。资源开销:例如虽然希望控制空闲连接(连接池此刻可马上使用的连接),但是不希望因为连接池的频繁释放创建连接造成不必靠开销。以一个例子说明,假设:一次命令时间(borrow|return resource + Jedis执行命令(含网络) )的平均耗时约为1ms,一个连接的QPS大约是1000业务期望的QPS是50000那么理论上需要的资源池大小是50000 / 1000 = 50个。但事实上这是个理论值,还要考虑到要比理论值预留一些资源,通常来讲maxTotal可以比理论值大一些。但这个值不是越大越好,一方面连接太多占用客户端和服务端资源,另一方面对于Redis这种高QPS的服务器,一个大命令的阻塞即使设置再大资源池仍然会无济于事。2)maxIdle和minIdlemaxIdle实际上才是业务需要的最大连接数,maxTotal是为了给出余量,所以maxIdle不要设置过小,否则会有new Jedis(新连接)开销。连接池的最佳性能是maxTotal = maxIdle,这样就避免连接池伸缩带来的性能干扰。但是如果并发量不大或者maxTotal设置过高,会导致不必要的连接资源浪费。一般推荐maxIdle可以设置为按上面的业务期望QPS计算出来的理论连接数,maxTotal可以再放大一倍。minIdle(最小空闲连接数),与其说是最小空闲连接数,不如说是\"至少需要保持的空闲连接数\",在使用连接的过程中,如果连接数超过了minIdle,那么继续建立连接,如果超过了maxIdle,当超过的连接执行完业务后会慢慢被移出连接池释放掉。如果系统启动完马上就会有很多的请求过来,那么可以给redis连接池做预热,比如快速的创建一些redis连接,执行简单命令,类似ping(),快速的将连接池里的空闲连接提升到minIdle的数量。连接池预热示例代码:
LRU 算法(Least Recently Used,最近最少使用)淘汰很久没被访问过的数据,以最近一次访问时间作为参考。LFU 算法(Least Frequently Used,最不经常使用)淘汰最近一段时间被访问次数最少的数据,以次数作为参考。当存在热点数据时,LRU的效率很好,但偶发性的、周期性的批量操作会导致LRU命中率急剧下降,缓存污染情况比较严重。这时使用LFU可能更好点。根据自身业务类型,配置好maxmemory-policy(默认是noeviction),推荐使用volatile-lru。如果不设置最大内存,当 Redis 内存超出物理内存限制时,内存的数据会开始和磁盘产生频繁的交换 (swap),会让 Redis 的性能急剧下降。当Redis运行在主从模式时,只有主结点才会执行过期删除策略,然后把删除操作”del key”同步到从结点删除数据。
3.【建议】高并发下建议客户端添加熔断功能(例如sentinel、hystrix)4.【推荐】设置合理的密码,如有必要可以使用SSL加密访问5.【建议】Redis对于过期键有三种清除策略:被动删除:当读/写一个已经过期的key时,会触发惰性删除策略,直接删除掉这个过期key主动删除:由于惰性删除策略无法保证冷数据被及时删掉,所以Redis会定期(默认每100ms)主动淘汰一批已过期的key,这里的一批只是部分过期key,所以可能会出现部分key已经过期但还没有被清理掉的情况,导致内存并没有被释放当前已用内存超过maxmemory限定时,触发主动清理策略主动清理策略在Redis 4.0 之前一共实现了 6 种内存淘汰策略,在 4.0 之后,又增加了 2 种策略,总共8种:a) 针对设置了过期时间的key做处理:volatile-ttl:在筛选时,会针对设置了过期时间的键值对,根据过期时间的先后进行删除,越早过期的越先被删除。volatile-random:就像它的名称一样,在设置了过期时间的键值对中,进行随机删除。volatile-lru:会使用 LRU 算法筛选设置了过期时间的键值对删除。volatile-lfu:会使用 LFU 算法筛选设置了过期时间的键值对删除。b) 针对所有的key做处理:allkeys-random:从所有键值对中随机选择并删除数据。allkeys-lru:使用 LRU 算法在所有数据中进行筛选删除。allkeys-lfu:使用 LFU 算法在所有数据中进行筛选删除。c) 不处理:noeviction:不会剔除任何数据,拒绝所有写入操作并返回客户端错误信息\"(error) OOM command not allowed when used memory\",此时Redis只响应读操作。
三、客户端使用1.【推荐】避免多个应用使用一个Redis实例正例:不相干的业务拆分,公共数据做服务化。2.【推荐】使用带有连接池的数据库,可以有效控制连接,同时提高效率,标准使用方式:
合理设置文件句柄数操作系统进程试图打开一个文件(或者叫句柄),但是现在进程打开的句柄数已经达到了上限,继续打开会报错:“Too many open files”ulimit -a #查看系统文件句柄数,看open files那项ulimit -n 65535 #设置系统文件句柄数
四、系统内核参数优化
开发规范与性能优化--------------------------------------------------------------------------------
Redis缓存设计和性能优化
数据结构redis 3.2 以前struct sdshdr { int len; int free; char buf[];};
#define SDS_TYPE_5 0#define SDS_TYPE_8 1#define SDS_TYPE_16 2#define SDS_TYPE_32 3#define SDS_TYPE_64 4
static inline char sdsReqType(size_t string_size) { if (string_size < 32) return SDS_TYPE_5; if (string_size < 0xff) //2^8 -1 return SDS_TYPE_8; if (string_size < 0xffff) // 2^16 -1 return SDS_TYPE_16; if (string_size < 0xffffffff) // 2^32 -1 return SDS_TYPE_32; return SDS_TYPE_64;}
String 常用API/> help @string /> SET/GET /> SETNX /> GETRANGE/SETRANGE/> INCR/INCRBY/DECR/DECRBY/> GETBIT/SETBIT/BITOPS/BITCOUNT/> MGET/MSET
List常用API/> help @list LPUSH key element [element ...]RPOP keyRPUSH key element [element ...]LPOP keyBLPOP key [key ...] timeoutBRPOP key [key ...] timeoutBRPOPLPUSH source destination timeoutRPOPLPUSH source destinationLINDEX key indexLLEN keyLINSERT key BEFORE|AFTER pivot elementLRANGE key start stopLREM key count elementLSET key index elementLTRIM key start stop
Hash常用API/> help @hash HSET key field value [field value ...]HGET key fieldHMGET key field [field ...]HKEYS keyHGETALL keyHVALS keyHEXISTS key fieldHDEL key field [field ...]HINCRBY key field incrementHINCRBYFLOAT key field incrementHLEN keyHSCAN key cursor [MATCH pattern] [COUNT count]HSETNX key field valueHSTRLEN key field
Set常用API/> help @set SADD key member [member ...] SCARD key SISMEMBER key member SPOP key [count] SDIFF key [key ...] SINTER key [key ...] SUNION key [key ...] SMEMBERS key SRANDMEMBER key [count] SREM key member [member ...] SMOVE source destination member SUNIONSTORE destination key [key ...] SDIFFSTORE destination key [key ...] SINTERSTORE destination key [key ...] SSCAN key cursor [MATCH pattern] [COUNT count]
ZSet常用API/> help @sorted_set ZADD key [NX|XX] [CH] [INCR] score member [score member ...] ZCARD key ZCOUNT key min max ZINCRBY key increment member ZRANGE key start stop [WITHSCORES] ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count] ZRANK key member ZREM key member [member ...] ZREMRANGEBYRANK key start stop ZREMRANGEBYSCORE key min max ZREVRANGE key start stop [WITHSCORES] ZREVRANGEBYSCORE key max min [WITHSCORES] [LIMIT offset count] ZREVRANK key member ZSCAN key cursor [MATCH pattern] [COUNT count] ZSCORE key member
Redis核心底层结构
redis 6.0 线程执行模式:可以通过如下参数配置多线程模型:如: io-threads 4 // 这里说 有三个IO 线程,还有一个线程是main线程,main线程负责IO读写和命令执行操作默认情况下,如上配置,有三个IO线程, 这三个IO线程只会执行 IO中的write 操作,也就是说,read 和 命令执行 都由main线程执行。最后多线程将数据写回到客户端。
开启了如下参数:io-threads-do-reads yes // 将支持IO线程执行 读写任务。
1. 多线程
2. client side caching 客户端缓存:redis 6 提供了服务端追踪key的变化,客户端缓存数据的特性,这需要客户端实现执行流程为, 当客户端访问某个key时,服务端将记录key 和 client ,客户端拿到数据后,进行客户端缓存,这时,当key再次被访问时,key将被直接返回,避免了与redis 服务器的再次交互,节省服务端资源,当数据被其他请求修改时,服务端将主动通知客户端失效的key,客户端进行本地失效,下次请求时,重新获取最新数据。
<dependency> <groupId>io.lettuce</groupId> <artifactId>lettuce-core</artifactId> <version>6.0.0.RELEASE</version></dependency>
目前只有lettuce对其进行了支持:
2. Client Side Cache
3. ACL 是对于命令的访问和执行权限的控制,默认情况下,可以有执行任意的指令,兼容以前版本
如上用户alice 没有任何意义。创建一个对 cached: 前缀具有get命令执行权限的用户,并且设置密码:acl setuser alice on >pass123 ~cached:* +get auth alice pass123set a a(error) NOPERM this user has no permissions to run the 'set' command or its subcommandget a a (error) NOPERM this user has no permissions to access one of the keys used as argumentsget cached:namevvv如上,如果访问没有被授权的命令,或者key, 将报错,set 命令没有被授权, key a 没有被授权,cached:name 可以通过验证。
更符合阅读习惯的格式ACL GETUSER alice添加多个访问模式,空格分隔, 注意,切换其他用户进行登录,alice没有admin权限ACL SETUSER alice ~objects:* ~items:* ~public:*
针对类型命令的约束ACL SETUSER alice on +@all -@dangerous >密码 ~*
这里+@all: 包含所有得命令 然后用-@ 去除在redis command table 中定义的 dangerous 命令可以通过如下命令进行查看具体有哪些命令属于某个类别acl cat // 查看所有类别acl cat dangerous // 查看所有的 dangerous 命令开放子命令ACL SETUSER myuser -client +client|setname +client|getname禁用client 命令,但是开放 client 命令中的子命令 setname 和 getname ,只能是先禁用,后追加子命令,因为后续可能会有新的命令增加。
命令方式:ACL SETUSER alice // 创建一个 用户名为 alice的用户用如上的命令创建的用户语义为:1. 处于 off 状态, 它是被禁用的,不能用auth进行认证2. 不能访问任何命令3. 不能访问任意的key4. 没有密码
ACL设置有两种方式:1. 命令方式 ACL SETUSER + 具体的权限规则, 通过 ACL SAVE 进行持久化2. 对 ACL 配置文件进行编写,并且执行 ACL LOAD 进行加载
3. Acls
4.Redis6.0默认是否开启了多线程?Redis6.0的多线程默认是禁用的,只使用主线程。如需开启需要修改redis.conf配置文件:io-threads-do-reads yes开启多线程后,还需要设置线程数,否则是不生效的。关于线程数的设置,官方有一个建议:4核的机器建议设置为2或3个线程,8核的建议设置为6个线程,线程数一定要小于机器核数。还需要注意的是,线程数并不是越大越好,官方认为超过了8个基本就没什么意义了。
5.Redis6.0采用多线程后,性能的提升效果如何?Redis 作者 antirez 在 RedisConf 2019分享时曾提到:Redis 6 引入的多线程 IO 特性对性能提升至少是一倍以上。国内也有大牛曾使用unstable版本在阿里云esc进行过测试,GET/SET 命令在4线程 IO时性能相比单线程是几乎是翻倍了。如果开启多线程,至少要4核的机器,且Redis实例已经占用相当大的CPU耗时的时候才建议采用,否则使用多线程没有意义。6.Redis6.0多线程的实现机制?流程简述如下:1、主线程负责接收建立连接请求,获取 socket 放入全局等待读处理队列2、主线程处理完读事件之后,通过 RR(Round Robin) 将这些连接分配给这些 IO 线程3、主线程阻塞等待 IO 线程读取 socket 完毕4、主线程通过单线程的方式执行请求命令,请求数据读取并解析完成,但并不执行回写 socket 5、主线程阻塞等待 IO 线程将数据回写 socket 完毕6、解除绑定,清空等待队列该设计有如下特点:1、IO 线程要么同时在读 socket,要么同时在写,不会同时读或写2、IO 线程只负责读写 socket 解析命令,不负责命令处理7.开启多线程后,是否会存在线程并发安全问题?从上面的实现机制可以看出,Redis的多线程部分只是用来处理网络数据的读写和协议解析,执行命令仍然是单线程顺序执行。所以我们不需要去考虑控制 key、lua、事务,LPUSH/LPOP 等等的并发及线程安全问题。8.Redis6.0的多线程和Memcached多线程模型进行对比Memcached 服务器采用 master-woker 模式进行工作,服务端采用 socket 与客户端通讯。主线程、工作线程 采用 pipe管道进行通讯。主线程采用 libevent 监听 listen、accept 的读事件,事件响应后将连接信息的数据结构封装起来,根据算法选择合适的工作线程,将连接任务携带连接信息分发出去,相应的线程利用连接描述符建立与客户端的socket连接 并进行后续的存取数据操作。相同点:都采用了 master线程-worker 线程的模型不同点:Memcached 执行主逻辑也是在 worker 线程里,模型更加简单,实现了真正的线程隔离,符合我们对线程隔离的常规理解。而 Redis 把处理逻辑交还给 master 线程,虽然一定程度上增加了模型复杂度,但也解决了线程并发安全等问题
Redis6新特性
pfcountpfcount key [key …]pfcount用于计算一个或多个HyperLogLog的独立总数,例如08-15:u:id的独立总数为4:pfcount 08-15:u:id如果此时向插入u1、u2、u3、u90,结果是5:pfadd 08-15:u:id \"u1\" \"u2\" \"u3\" \"u90\"pfcount 08-15:u:id如果我们继续往里面插入数据,比如插入100万条用户记录。内存增加非常少,但是pfcount 的统计结果会出现误差。以使用集合类型和 HperLogLog统计百万级用户访问次数的占用空间对比:数据类型 1天 1个月 1年集合类型 80M 2.4G 28GHyperLogLog 15k 450k 5M可以看到,HyperLogLog内存占用量小得惊人,但是用如此小空间来估算如此巨大的数据,必然不是100%的正确,其中一定存在误差率。前面说过,Redis官方给出的数字是0.81%的失误率。
pfmergepfmerge destkey sourcekey [sourcekey ... ]pfmerge可以求出多个HyperLogLog的并集并赋值给destkey,请自行测试。
操作命令HyperLogLog提供了3个命令: pfadd、pfcount、pfmerge。例如08-15的访问用户是u1、u2、u3、u4,08-16的访问用户是u-4、u-5、u-6、u-7
RedisHyperLoglog
管道(Pipeline)客户端可以一次性发送多个请求而不用等待服务器的响应,待所有命令都发送完后再一次性读取服务的响应,这样可以极大的降低多条命令执行的网络传输开销,管道执行多条命令的网络开销实际上只相当于一次命令执行的网络开销。需要注意到是用pipeline方式打包命令发送,redis必须在处理完所有命令前先缓存起所有命令的处理结果。打包的命令越多,缓存消耗内存也越多。所以并不是打包的命令越多越好。pipeline中发送的每个command都会被server立即执行,如果执行失败,将会在此后的响应中得到信息;也就是pipeline并不是表达“所有command都一起成功”的语义,管道中前面命令失败,后面命令不会有影响,继续执行。
Redis Lua脚本(放在后面Redis高并发分布式锁实战课里详细讲)Redis在2.6推出了脚本功能,允许开发者使用Lua语言编写脚本传到Redis中执行。使用脚本的好处如下:1、减少网络开销:本来5次网络请求的操作,可以用一个请求完成,原先5次请求的逻辑放在redis服务器上完成。使用脚本,减少了网络往返时延。这点跟管道类似。2、原子操作:Redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。管道不是原子的,不过redis的批量操作命令(类似mset)是原子的。3、替代redis的事务功能:redis自带的事务功能很鸡肋,而redis的lua脚本几乎实现了常规的事务功能,官方推荐如果要使用redis的事务功能可以用redis lua替代。
Redis事务
RedisTemplate中定义了对5种数据结构操作redisTemplate.opsForValue();//操作字符串redisTemplate.opsForHash();//操作hashredisTemplate.opsForList();//操作listredisTemplate.opsForSet();//操作setredisTemplate.opsForZSet();//操作有序set
StringRedisTemplate继承自RedisTemplate,也一样拥有上面这些操作。StringRedisTemplate默认采用的是String的序列化策略,保存的key和value都是采用此策略序列化保存的。RedisTemplate默认采用的是JDK的序列化策略,保存的key和value都是采用此策略序列化保存的。
Redis客户端命令对应的RedisTemplate中的方法列表:String类型结构 Redis RedisTemplate rtset key value rt.opsForValue().set(\"key\
StringRedisTemplate与RedisTemplate详解
Redis使用
Redis缓存数据库
其中loadClass的类加载过程有如下几步:加载 >> 验证 >> 准备 >> 解析 >> 初始化 >> 使用 >> 卸载* 加载:在硬盘上查找并通过IO读入字节码文件,使用到类时才会加载,例如调用类的main()方法,new对象等等,在加载阶段会在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口* 验证:校验字节码文件的正确性* 准备:给类的静态变量分配内存,并赋予默认值* 解析:将符号引用替换为直接引用,该阶段会把一些静态方法(符号引用,比如main()方法)替换为指向数据所存内存的指针或句柄等(直接引用),这是所谓的静态链接过程(类加载期间完成),动态链接是在程序运行期间完成的将符号引用替换为直接引用,下节课会讲到动态链接* 初始化:对类的静态变量初始化为指定的值,执行静态代码块
为什么要设计双亲委派机制?沙箱安全机制:自己写的java.lang.String.class类不会被加载,这样便可以防止核心API库被随意篡改避免类的重复加载:当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次,保证被加载类的唯一性
全盘负责委托机制“全盘负责”是指当一个ClassLoder装载一个类时,除非显示的使用另外一个ClassLoder,该类所依赖及引用的类也由这个ClassLoder载入。
打破双亲委派机制再来一个沙箱安全机制示例,尝试打破双亲委派机制,用自定义类加载器加载我们自己实现的 java.lang.String.class
Tomcat打破双亲委派机制以Tomcat类加载为例,Tomcat 如果使用默认的双亲委派类加载机制行不行?我们思考一下:Tomcat是个web容器, 那么它要解决什么问题: 1. 一个web容器可能需要部署两个应用程序,不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求同一个类库在同一个服务器只有一份,因此要保证每个应用程序的类库都是独立的,保证相互隔离。 2. 部署在同一个web容器中相同的类库相同的版本可以共享。否则,如果服务器有10个应用程序,那么要有10份相同的类库加载进虚拟机。 3. web容器也有自己依赖的类库,不能与应用程序的类库混淆。基于安全考虑,应该让容器的类库和程序的类库隔离开来。 4. web容器要支持jsp的修改,我们知道,jsp 文件最终也是要编译成class文件才能在虚拟机中运行,但程序运行后修改jsp已经是司空见惯的事情, web容器需要支持 jsp 修改后不用重启。再看看我们的问题:Tomcat 如果使用默认的双亲委派类加载机制行不行? 答案是不行的。为什么?第一个问题,如果使用默认的类加载器机制,那么是无法加载两个相同类库的不同版本的,默认的类加器是不管你是什么版本的,只在乎你的全限定类名,并且只有一份。第二个问题,默认的类加载器是能够实现的,因为他的职责就是保证唯一性。第三个问题和第一个问题一样。我们再看第四个问题,我们想我们要怎么实现jsp文件的热加载,jsp 文件其实也就是class文件,那么如果修改了,但类名还是一样,类加载器会直接取方法区中已经存在的,修改后的jsp是不会重新加载的。那么怎么办呢?我们可以直接卸载掉这jsp文件的类加载器,所以你应该想到了,每个jsp文件对应一个唯一的类加载器,当一个jsp文件修改了,就直接卸载这个jsp类加载器。重新创建类加载器,重新加载jsp文件。
tomcat的几个主要类加载器:commonLoader:Tomcat最基本的类加载器,加载路径中的class可以被Tomcat容器本身以及各个Webapp访问;catalinaLoader:Tomcat容器私有的类加载器,加载路径中的class对于Webapp不可见;sharedLoader:各个Webapp共享的类加载器,加载路径中的class对于所有Webapp可见,但是对于Tomcat容器不可见;WebappClassLoader:各个Webapp私有的类加载器,加载路径中的class只对当前Webapp可见,比如加载war包里相关的类,每个war包应用都有自己的WebappClassLoader,实现相互隔离,比如不同war包应用引入了不同的spring版本,这样实现就能加载各自的spring版本;从图中的委派关系中可以看出:CommonClassLoader能加载的类都可以被CatalinaClassLoader和SharedClassLoader使用,从而实现了公有类库的共用,而CatalinaClassLoader和SharedClassLoader自己能加载的类则与对方相互隔离。WebAppClassLoader可以使用SharedClassLoader加载到的类,但各个WebAppClassLoader实例之间相互隔离。而JasperLoader的加载范围仅仅是这个JSP文件所编译出来的那一个.Class文件,它出现的目的就是为了被丢弃:当Web容器检测到JSP文件被修改时,会替换掉目前的JasperLoader的实例,并通过再建立一个新的Jsp类加载器来实现JSP文件的热加载功能。tomcat 这种类加载机制违背了java 推荐的双亲委派模型了吗?答案是:违背了。 很显然,tomcat 不是这样实现,tomcat 为了实现隔离性,没有遵守这个约定,每个webappClassLoader加载自己的目录下的class文件,不会传递给父类加载器,打破了双亲委派机制。模拟实现Tomcat的webappClassLoader加载自己war包应用内不同版本类实现相互共存与隔离
类加载器和双亲委派机制上面的类加载过程主要是通过类加载器来实现的,Java里有如下几种类加载器引导类加载器:负责加载支撑JVM运行的位于JRE的lib目录下的核心类库,比如rt.jar、charsets.jar等扩展类加载器:负责加载支撑JVM运行的位于JRE的lib目录下的ext扩展目录中的JAR类包应用程序类加载器:负责加载ClassPath路径下的类包,主要就是加载你自己写的那些类自定义加载器:负责加载用户自定义路径下的类包类加载器初始化过程:参见类运行加载全过程图可知其中会创建JVM启动器实例sun.misc.Launcher。在Launcher构造方法内部,其创建了两个类加载器,分别是sun.misc.Launcher.ExtClassLoader(扩展类加载器)和sun.misc.Launcher.AppClassLoader(应用类加载器)。JVM默认使用Launcher的getClassLoader()方法返回的类加载器AppClassLoader的实例加载我们的应用程序。
通过Java命令执行代码的大体流程如下:
JVM类加载
JVM整体结构及内存模型
Spring Boot程序的JVM参数设置格式(Tomcat启动直接加在bin目录下catalina.sh文件里):java -Xms2048M -Xmx2048M -Xmn1024M -Xss512K -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -jar microservice-eureka-server.jar-Xss:每个线程的栈大小-Xms:设置堆的初始可用大小,默认物理内存的1/64 -Xmx:设置堆的最大可用大小,默认物理内存的1/4-Xmn:新生代大小-XX:NewRatio:默认2表示新生代占年老代的1/2,占整个堆内存的1/3。-XX:SurvivorRatio:默认8表示一个survivor区占用1/8的Eden内存,即1/10的新生代内存。关于元空间的JVM参数有两个:-XX:MetaspaceSize=N和 -XX:MaxMetaspaceSize=N-XX:MaxMetaspaceSize: 设置元空间最大值, 默认是-1, 即不限制, 或者说只受限于本地内存大小。-XX:MetaspaceSize: 指定元空间触发Fullgc的初始阈值(元空间无固定初始大小), 以字节为单位,默认是21M左右,达到该值就会触发full gc进行类型卸载, 同时收集器会对该值进行调整: 如果释放了大量的空间, 就适当降低该值; 如果释放了很少的空间, 那么在不超过-XX:MaxMetaspaceSize(如果设置了的话) 的情况下, 适当提高该值。这个跟早期jdk版本的-XX:PermSize参数意思不一样,-XX:PermSize代表永久代的初始容量。由于调整元空间的大小需要Full GC,这是非常昂贵的操作,如果应用在启动的时候发生大量Full GC,通常都是由于永久代或元空间发生了大小调整,基于这种情况,一般建议在JVM参数中将MetaspaceSize和MaxMetaspaceSize设置成一样的值,并设置得比初始值要大,对于8G物理内存的机器来说,一般我会将这两个值都设置为256M。
JVM内存参数大小该如何设置?JVM参数大小设置并没有固定标准,需要根据实际项目情况分析,给大家举个例子日均百万级订单交易系统如何设置JVM参数结论:通过上面这些内容介绍,大家应该对JVM优化有些概念了,就是尽可能让对象都在新生代里分配和回收,尽量别让太多对象频繁进入老年代,避免频繁对老年代进行垃圾回收,同时给系统充足的内存大小,避免新生代频繁的进行垃圾回收。
JVM内存参数设置
JVM内存模型
1.类加载检查 虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。 new指令对应到语言层面上讲是,new关键词、对象克隆、对象序列化等。
3.初始化零值内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头), 如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
32位对象头
64位对象头
4.设置对象头初始化零值之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头Object Header之中。在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、 实例数据(Instance Data)和对齐填充(Padding)。 HotSpot虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时 间戳等。对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
什么是java对象的指针压缩?1.jdk1.6 update14开始,在64bit操作系统中,JVM支持指针压缩2.jvm配置参数:UseCompressedOops,compressed--压缩、oop(ordinary object pointer)--对象指针3.启用指针压缩:-XX:+UseCompressedOops(默认开启),禁止指针压缩:-XX:-UseCompressedOops为什么要进行指针压缩?1.在64位平台的HotSpot中使用32位指针(实际存储用64位),内存使用会多出1.5倍左右,使用较大指针在主内存和缓存之间移动数据,占用较大宽带,同时GC也会承受较大压力2.为了减少64位平台下内存的消耗,启用指针压缩功能3.在jvm中,32位地址最大支持4G内存(2的32次方),可以通过对对象指针的存入堆内存时压缩编码、取出到cpu寄存器后解码方式进行优化(对象指针在堆中是32位,在寄存器中是35位,2的35次方=32G),使得jvm只用32位地址就可以支持更大的内存配置(小于等于32G)4.堆内存小于4G时,不需要启用指针压缩,jvm会直接去除高32位地址,即使用低虚拟地址空间5.堆内存大于32G时,压缩指针会失效,会强制使用64位(即8字节)来对java对象寻址,这就会出现1的问题,所以堆内存不要大于32G为好关于对齐填充:对于大部分处理器,对象以8字节整数倍来对齐填充都是最高效的存取方式。
5.执行<init>方法 执行<init>方法,即对象按照程序员的意愿进行初始化。对应到语言层面上讲,就是为属性赋值(注意,这与上面的赋零值不同,这是由程序员赋的值),和执行构造方法。
对象的创建对象创建的主要流程:
对象内存分配流程图
JVM对于这种情况可以通过开启逃逸分析参数(-XX:+DoEscapeAnalysis)来优化对象内存分配位置,使其通过标量替换优先分配在栈上(栈上分配),JDK7之后默认开启逃逸分析,如果要关闭使用参数(-XX:-DoEscapeAnalysis)标量替换:通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该对象成员变量分解若干个被这个方法使用的成员变量所代替,这些代替的成员变量在栈帧或寄存器上分配空间,这样就不会因为没有一大块连续空间导致对象内存不够分配。开启标量替换参数(-XX:+EliminateAllocations),JDK7之后默认开启。标量与聚合量:标量即不可被进一步分解的量,而JAVA的基本数据类型就是标量(如:int,long等基本数据类型以及reference类型等),标量的对立就是可以被进一步分解的量,而这种量称之为聚合量。而在JAVA中对象就是可以被进一步分解的聚合量。
对象栈上分配我们通过JVM内存分配可以知道JAVA中的对象都是在堆上进行分配,当对象没有被引用的时候,需要依靠GC进行回收内存,如果对象数量较多的时候,会给GC带来较大压力,也间接影响了应用的性能。为了减少临时对象在堆内分配的数量,JVM通过逃逸分析确定该对象不会被外部访问。如果不会逃逸可以将该对象在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。对象逃逸分析:就是分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中。
对象在Eden区分配大多数情况下,对象在新生代中 Eden 区分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次Minor GC。我们来进行实际测试一下。在测试之前我们先来看看 Minor GC和Full GC 有什么不同呢?Minor GC/Young GC:指发生新生代的的垃圾收集动作,Minor GC非常频繁,回收速度一般也比较快。Major GC/Full GC:一般会回收老年代 ,年轻代,方法区的垃圾,Major GC的速度一般会比Minor GC的慢10倍以上。Eden与Survivor区默认8:1:1大量的对象被分配在eden区,eden区满了后会触发minor gc,可能会有99%以上的对象成为垃圾被回收掉,剩余存活的对象会被挪到为空的那块survivor区,下一次eden区满了后又会触发minor gc,把eden区和survivor区垃圾对象回收,把剩余存活的对象一次性挪动到另外一块为空的survivor区,因为新生代的对象都是朝生夕死的,存活时间很短,所以JVM默认的8:1:1的比例是很合适的,让eden区尽量的大,survivor区够用即可,JVM默认有这个参数-XX:+UseAdaptiveSizePolicy(默认开启),会导致这个8:1:1比例自动变化,如果不想这个比例有变化可以设置参数-XX:-UseAdaptiveSizePolicy
大对象直接进入老年代大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。JVM参数 -XX:PretenureSizeThreshold 可以设置大对象的大小,如果对象超过设置大小会直接进入老年代,不会进入年轻代,这个参数只在 Serial 和ParNew两个收集器下有效。比如设置JVM参数:-XX:PretenureSizeThreshold=1000000 (单位是字节) -XX:+UseSerialGC ,再执行下上面的第一个程序会发现大对象直接进了老年代为什么要这样呢?为了避免为大对象分配内存时的复制操作而降低效率。
长期存活的对象将进入老年代既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为1。对象在 Survivor 中每熬过一次 MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,CMS收集器默认6岁,不同的垃圾收集器会略微有点不同),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。
对象动态年龄判断当前放对象的Survivor区域里(其中一块区域,放对象的那块s区),一批对象的总大小大于这块Survivor区域内存大小的50%(-XX:TargetSurvivorRatio可以指定),那么此时大于等于这批对象年龄最大值的对象,就可以直接进入老年代了,例如Survivor区域里现在有一批对象,年龄1+年龄2+年龄n的多个年龄对象总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代。这个规则其实是希望那些可能是长期存活的对象,尽早进入老年代。对象动态年龄判断机制一般是在minor gc之后触发的。
老年代空间分配担保机制年轻代每次minor gc之前JVM都会计算下老年代剩余可用空间如果这个可用空间小于年轻代里现有的所有对象大小之和(包括垃圾对象)就会看一个“-XX:-HandlePromotionFailure”(jdk1.8默认就设置了)的参数是否设置了如果有这个参数,就会看看老年代的可用内存大小,是否大于之前每一次minor gc后进入老年代的对象的平均大小。如果上一步结果是小于或者之前说的参数没有设置,那么就会触发一次Full gc,对老年代和年轻代一起回收一次垃圾,如果回收完还是没有足够空间存放新的对象就会发生\"OOM\"当然,如果minor gc之后剩余存活的需要挪动到老年代的对象大小还是大于老年代可用空间,那么也会触发full gc,full gc完之后如果还是没有空间放minor gc之后的存活对象,则也会发生“OOM”
对象内存分配
引用计数法给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加1;当引用失效,计数器就减1;任何时候计数器为0的对象就是不可能再被使用的。这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。 所谓对象之间的相互引用问题,如下面代码所示:除了对象objA 和 objB 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为0,于是引用计数算法无法通知 GC 回收器回收他们。
可达性分析算法将“GC Roots” 对象作为起点,从这些节点开始向下搜索引用的对象,找到的对象都标记为非垃圾对象,其余未标记的对象都是垃圾对象GC Roots根节点:线程栈的本地变量、静态变量、本地方法栈的变量等等
强引用:普通的变量引用public static User user = new User();
软引用:将对象用SoftReference软引用类型的对象包裹,正常情况不会被回收,但是GC做完后发现释放不出空间存放新的对象,则会把这些软引用的对象回收掉。软引用可用来实现内存敏感的高速缓存。public static SoftReference<User> user = new SoftReference<User>(new User());软引用在实际中有重要的应用,例如浏览器的后退按钮。按后退时,这个后退时显示的网页内容是重新进行请求还是从缓存中取出呢?这就要看具体的实现策略了。(1)如果一个网页在浏览结束时就进行内容的回收,则按后退查看前面浏览过的页面时,需要重新构建(2)如果将浏览过的网页存储到内存中会造成内存的大量浪费,甚至会造成内存溢出
弱引用:将对象用WeakReference软引用类型的对象包裹,弱引用跟没引用差不多,GC会直接回收掉,很少用public static WeakReference<User> user = new WeakReference<User>(new User());
常见引用类型java的引用类型一般分为四种:强引用、软引用、弱引用、虚引用强引用:普通的变量引用
对象内存回收堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡(即不能再被任何途径使用的对象)。
标记的前提是对象在进行可达性分析后发现没有与GC Roots相连接的引用链。1. 第一次标记并进行一次筛选。筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize方法,对象将直接被回收。2. 第二次标记如果这个对象覆盖了finalize方法,finalize方法是对象脱逃死亡命运的最后一次机会,如果对象要在finalize()中成功拯救自己,只要重新与引用链上的任何的一个对象建立关联即可,譬如把自己赋值给某个类变量或对象的成员变量,那在第二次标记时它将移除出“即将回收”的集合。如果对象这时候还没逃脱,那基本上它就真的被回收了。注意:一个对象的finalize()方法只会被执行一次,也就是说通过调用finalize方法自我救命的机会就一次。
finalize()方法最终判定对象是否存活即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历再次标记过程。
类需要同时满足下面3个条件才能算是 “无用的类” :该类所有的对象实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。加载该类的 ClassLoader 已经被回收。该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
如何判断一个类是无用的类方法区主要回收的是无用的类,那么如何判断一个类是无用的类呢?
JVM对象分配
分代收集理论当前虚拟机的垃圾收集都采用分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为几块。一般将java堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。比如在新生代中,每次收集都会有大量对象(近99%)死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。注意,“标记-清除”或“标记-整理”算法会比复制算法慢10倍以上。
标记-复制算法为了解决效率问题,“复制”收集算法出现了。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。
标记-清除算法算法分为“标记”和“清除”阶段:标记存活的对象, 统一回收所有未被标记的对象(一般选择这种);也可以反过来,标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象 。它是最基础的收集算法,比较简单,但是会带来两个明显的问题:效率问题 (如果需要标记的对象太多,效率不高)空间问题(标记清除后会产生大量不连续的碎片)
标记-整理算法根据老年代的特点特出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。
算法
Serial(串行)收集器是最基本、历史最悠久的垃圾收集器了。大家看名字就知道这个收集器是一个单线程收集器了。它的 “单线程” 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( \"Stop The World\" ),直到它收集结束。新生代采用复制算法,老年代采用标记-整理算法。虚拟机的设计者们当然知道Stop The World带来的不良用户体验,所以在后续的垃圾收集器设计中停顿时间在不断缩短(仍然还有停顿,寻找最优秀的垃圾收集器的过程仍然在继续)。但是Serial收集器有没有优于其他垃圾收集器的地方呢?当然有,它简单而高效(与其他收集器的单线程相比)。Serial收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。Serial Old收集器是Serial收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:一种用途是在JDK1.5以及以前的版本中与Parallel Scavenge收集器搭配使用,另一种用途是作为CMS收集器的后备方案。
1.1 Serial收集器(-XX:+UseSerialGC -XX:+UseSerialOldGC)
Parallel收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和Serial收集器类似。默认的收集线程数跟cpu核数相同,当然也可以用参数(-XX:ParallelGCThreads)指定收集线程数,但是一般不推荐修改。Parallel Scavenge收集器关注点是吞吐量(高效率的利用CPU)。CMS等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。所谓吞吐量就是CPU中用于运行用户代码的时间与CPU总消耗时间的比值。 Parallel Scavenge收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解的话,可以选择把内存管理优化交给虚拟机去完成也是一个不错的选择。新生代采用复制算法,老年代采用标记-整理算法。Parallel Old收集器是Parallel Scavenge收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及CPU资源的场合,都可以优先考虑 Parallel Scavenge收集器和Parallel Old收集器(JDK8默认的新生代和老年代收集器)。
ParNew收集器其实跟Parallel收集器很类似,区别主要在于它可以和CMS收集器配合使用。新生代采用复制算法它是许多运行在Server模式下的虚拟机的首要选择,除了Serial收集器外,只有它能与CMS收集器(真正意义上的并发收集器,后面会介绍到)配合工作。
1.3 ParNew收集器(-XX:+UseParNewGC)
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用,它是HotSpot虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。从名字中的Mark Sweep这两个词可以看出,CMS收集器是一种 “标记-清除”算法实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:初始标记: 暂停所有的其他线程(STW),并记录下gc roots直接能引用的对象,速度很快。并发标记: 并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程, 这个过程耗时较长但是不需要停顿用户线程, 可以与垃圾收集线程一起并发运行。因为用户程序继续运行,可能会有导致已经标记过的对象状态发生改变。重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录(主要是处理漏标问题),这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。主要用到三色标记里的增量更新算法(见下面详解)做重新标记。并发清理: 开启用户线程,同时GC线程开始对未标记的区域做清扫。这个阶段如果有新增对象会被标记为黑色不做任何处理(见下面三色标记算法详解)。并发重置:重置本次GC过程中的标记数据。
从它的名字就可以看出它是一款优秀的垃圾收集器,主要优点:并发收集、低停顿。但是它有下面几个明显的缺点:对CPU资源敏感(会和服务抢资源);无法处理浮动垃圾(在并发标记和并发清理阶段又产生垃圾,这种浮动垃圾只能等到下一次gc再清理了);它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生,当然通过参数-XX:+UseCMSCompactAtFullCollection可以让jvm在执行完标记清除后再做整理执行过程中的不确定性,会存在上一次垃圾回收还没执行完,然后垃圾回收又被触发的情况,特别是在并发标记和并发清理阶段会出现,一边回收,系统一边运行,也许没回收完就再次触发full gc,也就是\"concurrent mode failure\",此时会进入stop the world,用serial old垃圾收集器来回收
CMS的相关核心参数-XX:+UseConcMarkSweepGC:启用cms -XX:ConcGCThreads:并发的GC线程数-XX:+UseCMSCompactAtFullCollection:FullGC之后做压缩整理(减少碎片)-XX:CMSFullGCsBeforeCompaction:多少次FullGC之后压缩一次,默认是0,代表每次FullGC后都会压缩一次 -XX:CMSInitiatingOccupancyFraction: 当老年代使用达到该比例时会触发FullGC(默认是92,这是百分比)-XX:+UseCMSInitiatingOccupancyOnly:只使用设定的回收阈值(-XX:CMSInitiatingOccupancyFraction设定的值),如果不指定,JVM仅在第一次使用设定值,后续则会自动调整-XX:+CMSScavengeBeforeRemark:在CMS GC前启动一次minor gc,降低CMS GC标记阶段(也会对年轻代一起做标记,如果在minor gc就干掉了很多对垃圾对象,标记阶段就会减少一些标记时间)时的开销,一般CMS的GC耗时 80%都在标记阶段-XX:+CMSParallellnitialMarkEnabled:表示在初始标记的时候多线程执行,缩短STW-XX:+CMSParallelRemarkEnabled:在重新标记的时候多线程执行,缩短STW;
1.4 CMS收集器(-XX:+UseConcMarkSweepGC(old))
G1将Java堆划分为多个大小相等的独立区域(Region),JVM目标是不超过2048个Region(JVM源码里TARGET_REGION_NUMBER 定义),实际可以超过该值,但是不推荐。一般Region大小等于堆大小除以2048,比如堆大小为4096M,则Region大小为2M,当然也可以用参数\"-XX:G1HeapRegionSize\"手动指定Region大小,但是推荐默认的计算方式。G1保留了年轻代和老年代的概念,但不再是物理隔阂了,它们都是(可以不连续)Region的集合。默认年轻代对堆内存的占比是5%,如果堆大小为4096M,那么年轻代占据200MB左右的内存,对应大概是100个Region,可以通过“-XX:G1NewSizePercent”设置新生代初始占比,在系统运行中,JVM会不停的给年轻代增加更多的Region,但是最多新生代的占比不会超过60%,可以通过“-XX:G1MaxNewSizePercent”调整。年轻代中的Eden和Survivor对应的region也跟之前一样,默认8:1:1,假设年轻代现在有1000个region,eden区对应800个,s0对应100个,s1对应100个。一个Region可能之前是年轻代,如果Region进行了垃圾回收,之后可能又会变成老年代,也就是说Region的区域功能可能会动态变化。G1垃圾收集器对于对象什么时候会转移到老年代跟之前讲过的原则一样,唯一不同的是对大对象的处理,G1有专门分配大对象的Region叫Humongous区,而不是让大对象直接进入老年代的Region中。在G1中,大对象的判定规则就是一个大对象超过了一个Region大小的50%,比如按照上面算的,每个Region是2M,只要一个大对象超过了1M,就会被放入Humongous中,而且一个大对象如果太大,可能会横跨多个Region来存放。Humongous区专门存放短期巨型对象,不用直接进老年代,可以节约老年代的空间,避免因为老年代空间不够的GC开销。Full GC的时候除了收集年轻代和老年代之外,也会将Humongous区一并回收。
G1收集器一次GC(主要值Mixed GC)的运作过程大致分为以下几个步骤:初始标记(initial mark,STW):暂停所有的其他线程,并记录下gc roots直接能引用的对象,速度很快 ;并发标记(Concurrent Marking):同CMS的并发标记最终标记(Remark,STW):同CMS的重新标记筛选回收(Cleanup,STW):筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿STW时间(可以用JVM参数 -XX:MaxGCPauseMillis指定)来制定回收计划,比如说老年代此时有1000个Region都满了,但是因为根据预期停顿时间,本次垃圾回收可能只能停顿200毫秒,那么通过之前回收成本计算得知,可能回收其中800个Region刚好需要200ms,那么就只会回收800个Region(Collection Set,要回收的集合),尽量把GC导致的停顿时间控制在我们指定的范围内。这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。不管是年轻代或是老年代,回收算法主要用的是复制算法,将一个region中的存活对象复制到另一个region中,这种不会像CMS那样回收完因为有很多内存碎片还需要整理一次,G1采用复制算法回收几乎不会有太多内存碎片。(注意:CMS回收阶段是跟用户线程一起并发执行的,G1因为内部实现太复杂暂时没实现并发回收,不过到了ZGC,Shenandoah就实现了并发收集,Shenandoah可以看成是G1的升级版本)
G1收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的Region(这也就是它的名字Garbage-First的由来),比如一个Region花200ms能回收10M垃圾,另外一个Region花50ms能回收20M垃圾,在回收时间有限情况下,G1当然会优先选择后面这个Region回收。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限时间内可以尽可能高的收集效率。
被视为JDK1.7以上版本Java虚拟机的一个重要进化特征。它具备以下特点:并行与并发:G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短Stop-The-World停顿时间。部分其他收集器原本需要停顿Java线程来执行GC动作,G1收集器仍然可以通过并发的方式让java程序继续执行。分代收集:虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但是还是保留了分代的概念。空间整合:与CMS的“标记--清理”算法不同,G1从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的。可预测的停顿:这是G1相对于CMS的另一个大优势,降低停顿时间是G1 和 CMS 共同的关注点,但G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段(通过参数\"-XX:MaxGCPauseMillis\"指定)内完成垃圾收集。
YoungGCYoungGC并不是说现有的Eden区放满了就会马上触发,G1会计算下现在Eden区回收大概要多久时间,如果回收时间远远小于参数 -XX:MaxGCPauseMills 设定的值,那么增加年轻代的region,继续给新对象存放,不会马上做Young GC,直到下一次Eden区放满,G1计算回收时间接近参数 -XX:MaxGCPauseMills 设定的值,那么就会触发Young GCMixedGC
MixedGC不是FullGC,老年代的堆占有率达到参数(-XX:InitiatingHeapOccupancyPercent)设定的值则触发,回收所有的Young和部分Old(根据期望的GC停顿时间确定old区垃圾收集的优先顺序)以及大对象区,正常情况G1的垃圾收集是先做MixedGC,主要使用复制算法,需要把各个region中存活的对象拷贝到别的region里去,拷贝过程中如果发现没有足够的空region能够承载拷贝对象就会触发一次Full GC
Full GC停止系统程序,然后采用单线程进行标记、清理和压缩整理,好空闲出来一批Region来供下一次MixedGC使用,这个过程是非常耗时的。(Shenandoah优化成多线程收集了)
G1垃圾收集分类YoungGC
G1垃圾收集器优化建议假设参数 -XX:MaxGCPauseMills 设置的值很大,导致系统运行很久才会做年轻代gc,年轻代可能都占用了堆内存的60%了,此时才触发年轻代gc。那么存活下来的对象可能就会很多,此时就会导致Survivor区域放不下那么多的对象,就会进入老年代中。或者是你年轻代gc过后,存活下来的对象过多,导致进入Survivor区域后触发了动态年龄判定规则,达到了Survivor区域的50%,也会快速导致一些对象进入老年代中。所以这里核心还是在于调节 -XX:MaxGCPauseMills 这个参数的值,在保证他的年轻代gc别太频繁的同时,还得考虑每次gc过后的存活对象有多少,避免存活对象太多快速进入老年代,频繁触发mixed gc.
G1收集器参数设置 -XX:+UseG1GC:使用G1收集器 -XX:ParallelGCThreads:指定GC工作的线程数量 -XX:G1HeapRegionSize:指定分区大小(1MB~32MB,且必须是2的N次幂),默认将整堆划分为2048个分区 -XX:MaxGCPauseMillis:目标暂停时间(默认200ms) -XX:G1NewSizePercent:新生代内存初始空间(默认整堆5%,值配置整数,默认就是百分比) -XX:G1MaxNewSizePercent:新生代内存最大空间 -XX:TargetSurvivorRatio:Survivor区的填充容量(默认50%),Survivor区域里的一批对象(年龄1+年龄2+年龄n的多个年龄对象)总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代 -XX:MaxTenuringThreshold:最大年龄阈值(默认15) -XX:InitiatingHeapOccupancyPercent:老年代占用空间达到整堆内存阈值(默认45%),则执行新生代和老年代的混合收集(MixedGC),比如我们之前说的堆默认有2048个region,如果有接近1000个region都是老年代的region,则可能就要触发MixedGC了 -XX:G1MixedGCLiveThresholdPercent(默认85%) region中的存活对象低于这个值时才会回收该region,如果超过这个值,存活对象过多,回收的的意义不大。 -XX:G1MixedGCCountTarget:在一次回收过程中指定做几次筛选回收(默认8次),在最后一个筛选回收阶段可以回收一会,然后暂停回收,恢复系统运行,一会再开始回收,这样可以让系统不至于单次停顿时间过长。 -XX:G1HeapWastePercent(默认5%): gc过程中空出来的region是否充足阈值,在混合回收的时候,对Region回收都是基于复制算法进行的,都是把要回收的Region里的存活对象放入其他Region,然后这个Region中的垃圾对象全部清理掉,这样的话在回收过程就会不断空出来新的Region,一旦空闲出来的Region数量达到了堆内存的5%,此时就会立即停止混合回收,意味着本次混合回收就结束了。
什么场景适合使用G150%以上的堆被存活对象占用对象分配和晋升的速度变化非常大垃圾回收时间特别长,超过1秒8GB以上的堆内存(建议值)停顿时间是500ms以内
ZGC是一款JDK 11中新加入的具有实验性质的低延迟垃圾收集器,ZGC可以说源自于是Azul System公司开发的C4(Concurrent Continuously Compacting Collector) 收集器。
ZGC目标如下图所示,ZGC的目标主要有4个:支持TB量级的堆。我们生产环境的硬盘还没有上TB呢,这应该可以满足未来十年内,所有JAVA应用的需求了吧。 最大GC停顿时间不超10ms。目前一般线上环境运行良好的JAVA应用Minor GC停顿时间在10ms左右,Major GC一般都需要100ms以上(G1可以调节停顿时间,但是如果调的过低的话,反而会适得其反),之所以能做到这一点是因为它的停顿时间主要跟Root扫描有关,而Root数量和堆大小是没有任何关系的。 奠定未来GC特性的基础。最糟糕的情况下吞吐量会降低15%。这都不是事,停顿时间足够优秀。至于吞吐量,通过扩容分分钟解决。另外,Oracle官方提到了它最大的优点是:它的停顿时间不会随着堆的增大而增长!也就是说,几十G堆的停顿时间是10ms以下,几百G甚至上T堆的停顿时间也是10ms以下。
不分代(暂时)单代,即ZGC「没有分代」。我们知道以前的垃圾回收器之所以分代,是因为源于“「大部分对象朝生夕死」”的假设,事实上大部分系统的对象分配行为也确实符合这个假设。那么为什么ZGC就不分代呢?因为分代实现起来麻烦,作者就先实现出一个比较简单可用的单代版本,后续会优化。
ZGC内存布局ZGC收集器是一款基于Region内存布局的, 暂时不设分代的, 使用了读屏障、 颜色指针等技术来实现可并发的标记-整理算法的, 以低延迟为首要目标的一款垃圾收集器。ZGC的Region可以具有如图3-19所示的大、 中、 小三类容量:小型Region(Small Region) : 容量固定为2MB, 用于放置小于256KB的小对象。中型Region(Medium Region) : 容量固定为32MB, 用于放置大于等于256KB但小于4MB的对象。大型Region(Large Region) : 容量不固定, 可以动态变化, 但必须为2MB的整数倍, 用于放置4MB或以上的大对象。 每个大型Region中只会存放一个大对象, 这也预示着虽然名字叫作“大型Region”, 但它的实际容量完全有可能小于中型Region, 最小容量可低至4MB。 大型Region在ZGC的实现中是不会被重分配(重分配是ZGC的一种处理动作, 用于复制对象的收集器阶段, 稍后会介绍到)的, 因为复制一个大对象的代价非常高昂。
NUMA-awareNUMA对应的有UMA,UMA即Uniform Memory Access Architecture,NUMA就是Non Uniform Memory Access Architecture。UMA表示内存只有一块,所有CPU都去访问这一块内存,那么就会存在竞争问题(争夺内存总线访问权),有竞争就会有锁,有锁效率就会受到影响,而且CPU核心数越多,竞争就越激烈。NUMA的话每个CPU对应有一块内存,且这块内存在主板上离这个CPU是最近的,每个CPU优先访问这块内存,那效率自然就提高了:服务器的NUMA架构在中大型系统上一直非常盛行,也是高性能的解决方案,尤其在系统延迟方面表现都很优秀。ZGC是能自动感知NUMA架构并充分利用NUMA架构特性的。
并发标记(Concurrent Mark):与G1一样,并发标记是遍历对象图做可达性分析的阶段,它的初始标记(Mark Start)和最终标记(Mark End)也会出现短暂的停顿,与G1不同的是, ZGC的标记是在指针上而不是在对象上进行的, 标记阶段会更新颜色指针(见下面详解)中的Marked 0、 Marked 1标志位。并发预备重分配(Concurrent Prepare for Relocate):这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)。ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本。并发重分配(Concurrent Relocate):重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障(读屏障(见下面详解))所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”(Self-Healing)能力。ZGC的颜色指针因为“自愈”(Self-Healing)能力,所以只有第一次访问旧对象会变慢, 一旦重分配集中某个Region的存活对象都复制完毕后,这个Region就可以立即释放用于新对象的分配,但是转发表还得留着不能释放掉, 因为可能还有访问在使用这个转发表。并发重映射(Concurrent Remap):重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,但是ZGC中对象引用存在“自愈”功能,所以这个重映射操作并不是很迫切。ZGC很巧妙地把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正它们都是要遍历所有对象的,这样合并就节省了一次遍历对象图的开销。一旦所有指针都被修正之后, 原来记录新旧对象关系的转发表就可以释放掉了。
读屏障之前的GC都是采用Write Barrier,这次ZGC采用了完全不同的方案读屏障,这个是ZGC一个非常重要的特性。在标记和移动对象的阶段,每次「从堆里对象的引用类型中读取一个指针」的时候,都需要加上一个Load Barriers。那么我们该如何理解它呢?看下面的代码,第一行代码我们尝试读取堆中的一个对象引用obj.fieldA并赋给引用o(fieldA也是一个对象时才会加上读屏障)。如果这时候对象在GC时被移动了,接下来JVM就会加上一个读屏障,这个屏障会把读出的指针更新到对象的新地址上,并且把堆里的这个指针“修正”到原本的字段里。这样就算GC把对象移动了,读屏障也会发现并修正指针,于是应用代码就永远都会持有更新后的有效指针,而且不需要STW。那么,JVM是如何判断对象被移动过呢?就是利用上面提到的颜色指针,如果指针是Bad Color,那么程序还不能往下执行,需要「slow path」,修正指针;如果指针是Good Color,那么正常往下执行即可:❝ 这个动作是不是非常像JDK并发中用到的CAS自旋?读取的值发现已经失效了,需要重新读取。而ZGC这里是之前持有的指针由于GC后失效了,需要通过读屏障修正指针。❞ 后面3行代码都不需要加读屏障:Object p = o这行代码并没有从堆中读取数据;o.doSomething()也没有从堆中读取数据;obj.fieldB不是对象引用,而是原子类型。正是因为Load Barriers的存在,所以会导致配置ZGC的应用的吞吐量会变低。官方的测试数据是需要多出额外4%的开销:那么,判断对象是Bad Color还是Good Color的依据是什么呢?就是根据上一段提到的Colored Pointers的4个颜色位。当加上读屏障时,根据对象指针中这4位的信息,就能知道当前对象是Bad/Good Color了。PS:既然低42位指针可以支持4T内存,那么能否通过预约更多位给对象地址来达到支持更大内存的目的呢?答案肯定是不可以。因为目前主板地址总线最宽只有48bit,4位是颜色位,就只剩44位了,所以受限于目前的硬件,ZGC最大只能支持16T的内存,JDK13就把最大支持堆内存从4T扩大到了16T。
ZGC运作过程ZGC的运作过程大致可划分为以下四个大的阶段:
ZGC存在的问题ZGC最大的问题是浮动垃圾。ZGC的停顿时间是在10ms以下,但是ZGC的执行时间还是远远大于这个时间的。假如ZGC全过程需要执行10分钟,在这个期间由于对象分配速率很高,将创建大量的新对象,这些对象很难进入当次GC,所以只能在下次GC的时候进行回收,这些只能等到下次GC才能回收的对象就是浮动垃圾。ZGC没有分代概念,每次都需要进行全堆扫描,导致一些“朝生夕死”的对象没能及时的被回收。解决方案目前唯一的办法是增大堆的容量,使得程序得到更多的喘息时间,但是这个也是一个治标不治本的方案。如果需要从根本上解决这个问题,还是需要引入分代收集,让新生对象都在一个专门的区域中创建,然后专门针对这个区域进行更频繁、更快的收集。
ZGC参数设置启用ZGC比较简单,设置JVM参数即可:-XX:+UnlockExperimentalVMOptions 「-XX:+UseZGC」。调优也并不难,因为ZGC调优参数并不多,远不像CMS那么复杂。它和G1一样,可以调优的参数都比较少,大部分工作JVM能很好的自动完成。下图所示是ZGC可以调优的参数:
ZGC触发时机ZGC目前有4中机制触发GC:定时触发,默认为不使用,可通过ZCollectionInterval参数配置。预热触发,最多三次,在堆内存达到10%、20%、30%时触发,主要时统计GC时间,为其他GC机制使用。分配速率,基于正态分布统计,计算内存99.9%可能的最大分配速率,以及此速率下内存将要耗尽的时间点,在耗尽之前触发GC(耗尽时间 - 一次GC最大持续时间 - 一次GC检测周期时间)。主动触发,(默认开启,可通过ZProactive参数配置) 距上次GC堆内存增长10%,或超过5分钟时,对比距上次GC的间隔时间跟(49 * 一次GC的最大持续时间),超过则触发。
ZGC收集器(-XX:+UseZGC)参考文章:https://wiki.openjdk.java.net/display/zgc/Main
如何选择垃圾收集器优先调整堆的大小让服务器自己来选择如果内存小于100M,使用串行收集器如果是单核,并且没有停顿时间的要求,串行或JVM自己选择如果允许停顿时间超过1秒,选择并行或者JVM自己选如果响应时间最重要,并且不能超过1秒,使用并发收集器4G以下可以用parallel,4-8G可以用ParNew+CMS,8G以上可以用G1,几百G以上用ZGCJDK 1.8默认使用 Parallel(年轻代和老年代都是)JDK 1.9默认使用 G1
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。虽然我们对各个收集器进行比较,但并非为了挑选出一个最好的收集器。因为直到现在为止还没有最好的垃圾收集器出现,更加没有万能的垃圾收集器,我们能做的就是根据具体应用场景选择适合自己的垃圾收集器。试想一下:如果有一种四海之内、任何场景下都适用的完美收集器存在,那么我们的Java虚拟机就不会实现那么多不同的垃圾收集器了。
在并发标记的过程中,因为标记期间应用线程还在继续跑,对象间的引用可能发生变化,多标和漏标的情况就有可能发生。漏标的问题主要引入了三色标记算法来解决。三色标记算法是把Gc roots可达性分析遍历对象过程中遇到的对象, 按照“是否访问过”这个条件标记成以下三种颜色:黑色: 表示对象已经被垃圾收集器访问过, 且这个对象的所有引用都已经扫描过。 黑色的对象代表已经扫描过, 它是安全存活的, 如果有其他对象引用指向了黑色对象, 无须重新扫描一遍。 黑色对象不可能直接(不经过灰色对象) 指向某个白色对象。灰色: 表示对象已经被垃圾收集器访问过, 但这个对象上至少存在一个引用还没有被扫描过。白色: 表示对象尚未被垃圾收集器访问过。 显然在可达性分析刚刚开始的阶段, 所有的对象都是白色的, 若在分析结束的阶段, 仍然是白色的对象, 即代表不可达。
三色标记
在并发标记过程中,如果由于方法运行结束导致部分局部变量(gcroot)被销毁,这个gcroot引用的对象之前又被扫描过(被标记为非垃圾对象),那么本轮GC不会回收这部分内存。这部分本应该回收但是没有回收到的内存,被称之为“浮动垃圾”。浮动垃圾并不会影响垃圾回收的正确性,只是需要等到下一轮垃圾回收中才被清除。另外,针对并发标记(还有并发清理)开始后产生的新对象,通常的做法是直接全部当成黑色,本轮不会进行清除。这部分对象期间可能也会变为垃圾,这也算是浮动垃圾的一部分。
多标-浮动垃圾
漏标会导致被引用的对象被当成垃圾误删除,这是严重bug,必须解决,有两种解决方案: 增量更新(Incremental Update) 和原始快照(Snapshot At The Beginning,SATB) 。增量更新就是当黑色对象插入新的指向白色对象的引用关系时, 就将这个新插入的引用记录下来, 等并发扫描结束之后, 再将这些记录过的引用关系中的黑色对象为根, 重新扫描一次。 这可以简化理解为, 黑色对象一旦新插入了指向白色对象的引用之后, 它就变回灰色对象了。原始快照就是当灰色对象要删除指向白色对象的引用关系时, 就将这个要删除的引用记录下来, 在并发扫描结束之后, 再将这些记录过的引用关系中的灰色对象为根, 重新扫描一次,这样就能扫描到白色的对象,将白色对象直接标记为黑色(目的就是让这种对象在本轮gc清理中能存活下来,待下一轮gc的时候重新扫描,这个对象也有可能是浮动垃圾)以上无论是对引用关系记录的插入还是删除, 虚拟机的记录操作都是通过写屏障实现的。
写屏障实现SATB当对象B的成员变量的引用发生变化时,比如引用消失(a.b.d = null),我们可以利用写屏障,将B原来成员变量的引用对象D记录下来:void pre_write_barrier(oop* field) { oop old_value = *field; // 获取旧值 remark_set.add(old_value); // 记录原来的引用对象}
读屏障oop oop_field_load(oop* field) { pre_load_barrier(field); // 读屏障-读取前操作 return *field;}读屏障是直接针对第一步:D d = a.b.d,当读取成员变量时,一律记录下来:void pre_load_barrier(oop* field) { oop old_value = *field; remark_set.add(old_value); // 记录读取到的对象}现代追踪式(可达性分析)的垃圾回收器几乎都借鉴了三色标记的算法思想,尽管实现的方式不尽相同:比如白色/黑色集合一般都不会出现(但是有其他体现颜色的地方)、灰色集合可以通过栈/队列/缓存日志等方式进行实现、遍历方式可以是广度/深度遍历等等。对于读写屏障,以Java HotSpot VM为例,其并发标记时对漏标的处理方案如下:
漏标-读写屏障
对于读写屏障,以Java HotSpot VM为例,其并发标记时对漏标的处理方案如下:CMS:写屏障 + 增量更新G1,Shenandoah:写屏障 + SATBZGC:读屏障工程实现中,读写屏障还有其他功能,比如写屏障可以用于记录跨代/区引用的变化,读屏障可以用于支持移动对象的并发执行等。功能之外,还有性能的考虑,所以对于选择哪种,每款垃圾回收器都有自己的想法。
为什么G1用SATB?CMS用增量更新?我的理解:SATB相对增量更新效率会高(当然SATB可能造成更多的浮动垃圾),因为不需要在重新标记阶段再次深度扫描被删除引用对象,而CMS对增量引用的根对象会做深度扫描,G1因为很多对象都位于不同的region,CMS就一块老年代区域,重新深度扫描对象的话G1的代价会比CMS高,所以G1选择SATB不深度扫描对象,只是简单标记,等到下一轮GC再深度扫描。
在新生代做GCRoots可达性扫描过程中可能会碰到跨代引用的对象,这种如果又去对老年代再去扫描效率太低了。为此,在新生代可以引入记录集(Remember Set)的数据结构(记录从非收集区到收集区的指针集合),避免把整个老年代加入GCRoots扫描范围。事实上并不只是新生代、 老年代之间才有跨代引用的问题, 所有涉及部分区域收集(Partial GC) 行为的垃圾收集器, 典型的如G1、 ZGC和Shenandoah收集器, 都会面临相同的问题。垃圾收集场景中,收集器只需通过记忆集判断出某一块非收集区域是否存在指向收集区域的指针即可,无需了解跨代引用指针的全部细节。hotspot使用一种叫做“卡表”(Cardtable)的方式实现记忆集,也是目前最常用的一种方式。关于卡表与记忆集的关系, 可以类比为Java语言中HashMap与Map的关系。卡表是使用一个字节数组实现:CARD_TABLE[ ],每个元素对应着其标识的内存区域一块特定大小的内存块,称为“卡页”。hotSpot使用的卡页是2^9大小,即512字节一个卡页中可包含多个对象,只要有一个对象的字段存在跨代指针,其对应的卡表的元素标识就变成1,表示该元素变脏,否则为0.GC时,只要筛选本收集区的卡表中变脏的元素加入GCRoots里。卡表的维护卡表变脏上面已经说了,但是需要知道如何让卡表变脏,即发生引用字段赋值时,如何更新卡表对应的标识为1。Hotspot使用写屏障维护卡表状态。
记忆集与卡表
垃圾收集底层算法实现
安全区域又是什么?Safe Point 是对正在执行的线程设定的。如果一个线程处于 Sleep 或中断状态,它就不能响应 JVM 的中断请求,再运行到 Safe Point 上。因此 JVM 引入了 Safe Region。Safe Region 是指在一段代码片段中,引用关系不会发生变化。在这个区域内的任意地方开始 GC 都是安全的。
垃圾收集器
jmap -histo 14660 #查看历史生成的实例jmap -histo:live 14660 #查看当前存活的实例,执行过程中可能会触发一次full gcnum:序号instances:实例数量bytes:占用空间大小class name:类名称,[C is a char[],[S is a short[],[I is a int[],[B is a byte[],[[I is a int[][]
堆信息
可以用jvisualvm命令工具导入该dump文件分析
Jmap此命令可以用来查看内存信息,实例个数以及占用内存大小
\"Thread-1\" 线程名 prio=5 优先级=5tid=0x000000001fa9e000 线程idnid=0x2d64 线程对应的本地线程标识nidjava.lang.Thread.State: BLOCKED 线程状态
启动普通的jar程序JMX端口配置:java -Dcom.sun.management.jmxremote.port=8888 -Djava.rmi.server.hostname=192.168.65.60 -Dcom.sun.management.jmxremote.ssl=false -Dcom.sun.management.jmxremote.authenticate=false -jar microservice-eureka-server.jarPS:-Dcom.sun.management.jmxremote.port 为远程机器的JMX端口-Djava.rmi.server.hostname 为远程机器IPtomcat的JMX配置:在catalina.sh文件里的最后一个JAVA_OPTS的赋值语句下一行增加如下配置行JAVA_OPTS=\"$JAVA_OPTS -Dcom.sun.management.jmxremote.port=8888 -Djava.rmi.server.hostname=192.168.50.60 -Dcom.sun.management.jmxremote.ssl=false -Dcom.sun.management.jmxremote.authenticate=false\"连接时确认下端口是否通畅,可以临时关闭下防火墙systemctl stop firewalld #临时关闭防火墙
还可以用jvisualvm自动检测死锁
用jstack加进程id查找死锁,见如下示例
1,使用命令top -p <pid> ,显示你的java进程的内存情况,pid是你的java进程号,比如19663
2,按H,获取每个线程的内存情况
3,找到内存和cpu占用最高的线程tid,比如196644,转为十六进制得到 0x4cd0,此为线程id的十六进制表示
5,执行 jstack 19663|grep -A 10 4cd0,得到线程堆栈信息中 4cd0 这个线程所在行的后面10行,从堆栈中可以发现导致cpu飙高的调用方法
6,查看对应的堆栈信息找出可能存在问题的代码
jstack找出占用cpu最高的线程堆栈信息
Jstack
查看正在运行的Java应用程序的扩展参数 查看jvm的参数
查看java系统参数
Jinfo
垃圾回收统计jstat -gc pid 最常用,可以评估程序内存使用及GC压力整体情况S0C:第一个幸存区的大小,单位KBS1C:第二个幸存区的大小S0U:第一个幸存区的使用大小S1U:第二个幸存区的使用大小EC:伊甸园区的大小EU:伊甸园区的使用大小OC:老年代大小OU:老年代使用大小MC:方法区大小(元空间)MU:方法区使用大小CCSC:压缩类空间大小CCSU:压缩类空间使用大小YGC:年轻代垃圾回收次数YGCT:年轻代垃圾回收消耗时间,单位sFGC:老年代垃圾回收次数 FGCT:老年代垃圾回收消耗时间,单位sGCT:垃圾回收消耗总时间,单位s
堆内存统计NGCMN:新生代最小容量NGCMX:新生代最大容量NGC:当前新生代容量S0C:第一个幸存区大小S1C:第二个幸存区的大小EC:伊甸园区的大小OGCMN:老年代最小容量OGCMX:老年代最大容量OGC:当前老年代大小OC:当前老年代大小MCMN:最小元数据容量MCMX:最大元数据容量MC:当前元数据空间大小CCSMN:最小压缩类空间大小CCSMX:最大压缩类空间大小CCSC:当前压缩类空间大小YGC:年轻代gc次数FGC:老年代GC次数
新生代垃圾回收统计S0C:第一个幸存区的大小S1C:第二个幸存区的大小S0U:第一个幸存区的使用大小S1U:第二个幸存区的使用大小TT:对象在新生代存活的次数MTT:对象在新生代存活的最大次数DSS:期望的幸存区大小EC:伊甸园区的大小EU:伊甸园区的使用大小YGC:年轻代垃圾回收次数YGCT:年轻代垃圾回收消耗时间
新生代内存统计NGCMN:新生代最小容量NGCMX:新生代最大容量NGC:当前新生代容量S0CMX:最大幸存1区大小S0C:当前幸存1区大小S1CMX:最大幸存2区大小S1C:当前幸存2区大小ECMX:最大伊甸园区大小EC:当前伊甸园区大小YGC:年轻代垃圾回收次数FGC:老年代回收次数
老年代垃圾回收统计MC:方法区大小MU:方法区使用大小CCSC:压缩类空间大小CCSU:压缩类空间使用大小OC:老年代大小OU:老年代使用大小YGC:年轻代垃圾回收次数FGC:老年代垃圾回收次数FGCT:老年代垃圾回收消耗时间GCT:垃圾回收消耗总时间
老年代内存统计OGCMN:老年代最小容量OGCMX:老年代最大容量OGC:当前老年代大小OC:老年代大小YGC:年轻代垃圾回收次数FGC:老年代垃圾回收次数FGCT:老年代垃圾回收消耗时间GCT:垃圾回收消耗总时间
元数据空间统计MCMN:最小元数据容量MCMX:最大元数据容量MC:当前元数据空间大小 CCSMN:最小压缩类空间大小CCSMX:最大压缩类空间大小CCSC:当前压缩类空间大小YGC:年轻代垃圾回收次数FGC:老年代垃圾回收次数FGCT:老年代垃圾回收消耗时间GCT:垃圾回收消耗总时间
S0:幸存1区当前使用比例S1:幸存2区当前使用比例E:伊甸园区使用比例O:老年代使用比例M:元数据区使用比例CCS:压缩使用比例YGC:年轻代垃圾回收次数FGC:老年代垃圾回收次数FGCT:老年代垃圾回收消耗时间GCT:垃圾回收消耗总时间
Jstatjstat命令可以查看堆内存各部分的使用量,以及加载类的数量。命令的格式如下:jstat [-命令选项] [vmid] [间隔时间(毫秒)] [查询次数]注意:使用的jdk版本是jdk8
JVM运行情况预估用 jstat gc -pid 命令可以计算出如下一些关键数据,有了这些数据就可以采用之前介绍过的优化思路,先给自己的系统设置一些初始性的JVM参数,比如堆内存大小,年轻代大小,Eden和Survivor的比例,老年代的大小,大对象的阈值,大龄对象进入老年代的阈值等。年轻代对象增长的速率可以执行命令 jstat -gc pid 1000 10 (每隔1秒执行1次命令,共执行10次),通过观察EU(eden区的使用)来估算每秒eden大概新增多少对象,如果系统负载不高,可以把频率1秒换成1分钟,甚至10分钟来观察整体情况。注意,一般系统可能有高峰期和日常期,所以需要在不同的时间分别估算不同情况下对象增长速率。Young GC的触发频率和每次耗时知道年轻代对象增长速率我们就能推根据eden区的大小推算出Young GC大概多久触发一次,Young GC的平均耗时可以通过 YGCT/YGC 公式算出,根据结果我们大概就能知道系统大概多久会因为Young GC的执行而卡顿多久。每次Young GC后有多少对象存活和进入老年代这个因为之前已经大概知道Young GC的频率,假设是每5分钟一次,那么可以执行命令 jstat -gc pid 300000 10 ,观察每次结果eden,survivor和老年代使用的变化情况,在每次gc后eden区使用一般会大幅减少,survivor和老年代都有可能增长,这些增长的对象就是每次Young GC后存活的对象,同时还可以看出每次Young GC后进去老年代大概多少对象,从而可以推算出老年代对象增长速率。Full GC的触发频率和每次耗时知道了老年代对象的增长速率就可以推算出Full GC的触发频率了,Full GC的每次耗时可以用公式 FGCT/FGC 计算得出。优化思路其实简单来说就是尽量让每次Young GC后的存活对象小于Survivor区域的50%,都留存在年轻代里。尽量别让对象进入老年代。尽量减少Full GC的频率,避免频繁Full GC对JVM性能的影响。
JVM参数设置如下:-Xms1536M -Xmx1536M -Xmn512M -Xss256K -XX:SurvivorRatio=6 -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=75 -XX:+UseCMSInitiatingOccupancyOnly大家可以结合对象挪动到老年代那些规则推理下我们这个程序可能存在的一些问题经过分析感觉可能会由于对象动态年龄判断机制导致full gc较为频繁
为了给大家看效果,我模拟了一个示例程序(见课程对应工程代码:jvm-full-gc),打印了jstat的结果如下:jstat -gc 13456 2000 10000
对于对象动态年龄判断机制导致的full gc较为频繁可以先试着优化下JVM参数,把年轻代适当调大点:-Xms1536M -Xmx1536M -Xmn1024M -Xss256K -XX:SurvivorRatio=6 -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=92 -XX:+UseCMSInitiatingOccupancyOnly
优化完发现没什么变化,full gc的次数比minor gc的次数还多了
我们可以推测下full gc比minor gc还多的原因有哪些?1、元空间不够导致的多余full gc2、显示调用System.gc()造成多余的full gc,这种一般线上尽量通过-XX:+DisableExplicitGC参数禁用,如果加上了这个JVM启动参数,那么代码中调用System.gc()没有任何效果3、老年代空间分配担保机制
最快速度分析完这些我们推测的原因以及优化后,我们发现young gc和full gc依然很频繁了,而且看到有大量的对象频繁的被挪动到老年代,这种情况我们可以借助jmap命令大概看下是什么对象
内存泄露到底是怎么回事再给大家讲一种情况,一般电商架构可能会使用多级缓存架构,就是redis加上JVM级缓存,大多数同学可能为了图方便对于JVM级缓存就简单使用一个hashmap,于是不断往里面放缓存数据,但是很少考虑这个map的容量问题,结果这个缓存map越来越大,一直占用着老年代的很多空间,时间长了就会导致full gc非常频繁,这就是一种内存泄漏,对于一些老旧数据没有及时清理导致一直占用着宝贵的内存资源,时间长了除了导致full gc,还有可能导致OOM。这种情况完全可以考虑采用一些成熟的JVM级缓存框架来解决,比如ehcache等自带一些LRU数据淘汰算法的框架来作为JVM级的缓存。
系统频繁Full GC导致系统卡顿是怎么回事机器配置:2核4GJVM内存大小:2G系统运行时间:7天期间发生的Full GC次数和耗时:500多次,200多秒期间发生的Young GC次数和耗时:1万多次,500多秒大致算下来每天会发生70多次Full GC,平均每小时3次,每次Full GC在400毫秒左右;每天会发生1000多次Young GC,每分钟会发生1次,每次Young GC在50毫秒左右。
Arthas使用场景得益于 Arthas 强大且丰富的功能,让 Arthas 能做的事情超乎想象。下面仅仅列举几项常见的使用情况,更多的使用场景可以在熟悉了 Arthas 之后自行探索。是否有一个全局视角来查看系统的运行状况?为什么 CPU 又升高了,到底是哪里占用了 CPU ?运行的多线程有死锁吗?有阻塞吗?程序运行耗时很长,是哪里耗时比较长呢?如何监测呢?这个类从哪个 jar 包加载的?为什么会报各种类相关的 Exception?我改的代码为什么没有执行到?难道是我没 commit?分支搞错了?遇到问题无法在线上 debug,难道只能通过加日志再重新发布吗?有什么办法可以监控到 JVM 的实时运行状态?
选择进程序号1,进入进程信息操作
输入dashboard可以查看整个进程的运行情况,线程、内存、GC、运行环境信息:
输入thread可以查看线程详细情况
输入 thread加上线程ID 可以查看线程堆栈
输入 thread -b 可以查看线程死锁
输入 jad加类的全名 可以反编译,这样可以方便我们查看线上代码是否是正确的版本
使用 ognl 命令可以
Arthas使用# github下载arthaswget https://alibaba.github.io/arthas/arthas-boot.jar# 或者 Gitee 下载wget https://arthas.gitee.io/arthas-boot.jar用java -jar运行即可,可以识别机器上所有Java进程
阿里巴巴Arthas详解Arthas 是 Alibaba 在 2018 年 9 月开源的 Java 诊断工具。支持 JDK6+, 采用命令行交互模式,可以方便的定位和诊断线上程序运行问题。Arthas 官方文档十分详细,详见:https://alibaba.github.io/arthas
JVM调优工具
对于java应用我们可以通过一些配置把程序运行过程中的gc日志全部打印出来,然后分析gc日志得到关键性指标,分析GC原因,调优JVM参数。打印GC日志方法,在JVM参数里增加参数,%t 代表时间-Xloggc:./gc-%t.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps -XX:+PrintGCCause -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=100MTomcat则直接加在JAVA_OPTS变量里。
我们可以看到图中第一行红框,是项目的配置参数。这里不仅配置了打印GC日志,还有相关的VM内存参数。 第二行红框中的是在这个GC时间点发生GC之后相关GC情况。 1、对于2.909: 这是从jvm启动开始计算到这次GC经过的时间,前面还有具体的发生时间日期。 2、Full GC(Metadata GC Threshold)指这是一次full gc,括号里是gc的原因, PSYoungGen是年轻代的GC,ParOldGen是老年代的GC,Metaspace是元空间的GC3、 6160K->0K(141824K),这三个数字分别对应GC之前占用年轻代的大小,GC之后年轻代占用,以及整个年轻代的大小。 4、112K->6056K(95744K),这三个数字分别对应GC之前占用老年代的大小,GC之后老年代占用,以及整个老年代的大小。 5、6272K->6056K(237568K),这三个数字分别对应GC之前占用堆内存的大小,GC之后堆内存占用,以及整个堆内存的大小。 6、20516K->20516K(1069056K),这三个数字分别对应GC之前占用元空间内存的大小,GC之后元空间内存占用,以及整个元空间内存的大小。 7、0.0209707是该时间点GC总耗费时间。
从日志可以发现几次fullgc都是由于元空间不够导致的,所以我们可以将元空间调大点java -jar -Xloggc:./gc-adjust-%t.log -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps -XX:+PrintGCCause -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=100M microservice-eureka-server.jar调整完我们再看下gc日志发现已经没有因为元空间不够导致的fullgc了
CMS-Xloggc:d:/gc-cms-%t.log -Xms50M -Xmx50M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps -XX:+PrintGCCause -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=100M -XX:+UseParNewGC -XX:+UseConcMarkSweepGC
G1-Xloggc:d:/gc-g1-%t.log -Xms50M -Xmx50M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps -XX:+PrintGCCause -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=100M -XX:+UseG1GC
上面的这些参数,能够帮我们查看分析GC的垃圾收集情况。但是如果GC日志很多很多,成千上万行。就算你一目十行,看完了,脑子也是一片空白。所以我们可以借助一些功能来帮助我们分析,这里推荐一个gceasy(https://gceasy.io),可以上传gc文件,然后他会利用可视化的界面来展现GC情况。具体下图所示
对于CMS和G1收集器的日志会有一点不一样,也可以试着打印下对应的gc日志分析下,可以发现gc日志里面的gc步骤跟我们之前讲过的步骤是类似的
如何分析GC日志运行程序加上对应gc日志java -jar -Xloggc:./gc-%t.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps -XX:+PrintGCCause -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=100M microservice-eureka-server.jar
JVM参数汇总查看命令--------------------------------------------------------------------------------java -XX:+PrintFlagsInitial 表示打印出所有参数选项的默认值java -XX:+PrintFlagsFinal 表示打印出所有参数选项在运行程序时生效的值
一个class文件的16进制大体结构如下图:
对应的含义如下,细节可以查下oracle官方文档
字面量字面量就是指由字母、数字等构成的字符串或者数值常量字面量只可以右值出现,所谓右值是指等号右边的值,如:int a=1 这里的a为左值,1为右值。在这个例子中1就是字面量。int a = 1;int b = 2;int c = \"abcdefg\";int d = \"abcdefg\";
符号引用符号引用是编译原理中的概念,是相对于直接引用来说的。主要包括了以下三类常量:类和接口的全限定名 字段的名称和描述符 方法的名称和描述符上面的a,b就是字段名称,就是一种符号引用,还有Math类常量池里的 Lcom/tuling/jvm/Math 是类的全限定名,main和compute是方法名称,()是一种UTF8格式的描述符,这些都是符号引用。这些常量池现在是静态信息,只有到运行时被加载到内存后,这些符号才有对应的内存地址信息,这些常量池一旦被装入内存就变成运行时常量池,对应的符号引用在程序加载或运行时会被转变为被加载到内存区域的代码的直接引用,也就是我们说的动态链接了。例如,compute()这个符号引用在运行时就会被转变为compute()方法具体代码在内存中的地址,主要通过对象头里的类型指针去转换直接引用。
当然我们一般不会去人工解析这种16进制的字节码文件,我们一般可以通过javap命令生成更可读的JVM字节码指令文件:javap -v Math.class红框标出的就是class常量池信息,常量池中主要存放两大类常量:字面量和符号引用。
Class常量池与运行时常量池--------------------------------------------------------------------------------Class常量池可以理解为是Class文件中的资源仓库。 Class文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(constant pool table),用于存放编译期生成的各种字面量(Literal)和符号引用(Symbolic References)。
三种字符串操作(Jdk1.7 及以上版本)直接赋值字符串String s = \"zhuge\"; // s指向常量池中的引用这种方式创建的字符串对象,只会在常量池中。因为有\"zhuge\"这个字面量,创建对象s的时候,JVM会先去常量池中通过 equals(key) 方法,判断是否有相同的对象如果有,则直接返回该对象在常量池中的引用;如果没有,则会在常量池中创建一个新对象,再返回引用。new String();String s1 = new String(\"zhuge\"); // s1指向内存中的对象引用这种方式会保证字符串常量池和堆中都有这个对象,没有就创建,最后返回堆内存中的对象引用。步骤大致如下:因为有\"zhuge\"这个字面量,所以会先检查字符串常量池中是否存在字符串\"zhuge\"不存在,先在字符串常量池里创建一个字符串对象;再去内存中创建一个字符串对象\"zhuge\";存在的话,就直接去堆内存中创建一个字符串对象\"zhuge\";最后,将内存中的引用返回。intern方法String s1 = new String(\"zhuge\"); String s2 = s1.intern();System.out.println(s1 == s2); //falseString中的intern方法是一个 native 的方法,当调用 intern方法时,如果池已经包含一个等于此String对象的字符串(用equals(oject)方法确定),则返回池中的字符串。否则,将intern返回的引用指向当前字符串 s1(jdk1.6版本需要将 s1 复制到字符串常量池里)。
字符串常量池设计原理 字符串常量池底层是hotspot的C++实现的,底层类似一个 HashTable, 保存的本质上是字符串对象的引用。看一道比较常见的面试题,下面的代码创建了多少个 String 对象?String s1 = new String(\"he\") + new String(\"llo\");String s2 = s1.intern(); System.out.println(s1 == s2);// 在 JDK 1.6 下输出是 false,创建了 6 个对象// 在 JDK 1.7 及以上的版本输出是 true,创建了 5 个对象// 当然我们这里没有考虑GC,但这些对象确实存在或存在过
1、在 JDK 1.6 中,调用 intern() 首先会在字符串池中寻找 equal() 相等的字符串,假如字符串存在就返回该字符串在字符串池中的引用;假如字符串不存在,虚拟机会重新在永久代上创建一个实例,将 StringTable 的一个表项指向这个新创建的实例。
2、在 JDK 1.7 (及以上版本)中,由于字符串池不在永久代了,intern() 做了一些修改,更方便地利用堆中的对象。字符串存在时和 JDK 1.6一样,但是字符串不存在时不再需要重新创建实例,可以直接指向堆上的实例。
由上面两个图,也不难理解为什么 JDK 1.6 字符串池溢出会抛出 OutOfMemoryError: PermGen space ,而在 JDK 1.7 及以上版本抛出 OutOfMemoryError: Java heap space 。
为什么输出会有这些变化呢?主要还是字符串池从永久代中脱离、移入堆区的原因, intern() 方法也相应发生了变化:
示例1:String s0=\"zhuge\";String s1=\"zhuge\";String s2=\"zhu\" + \"ge\";System.out.println( s0==s1 ); //trueSystem.out.println( s0==s2 ); //true分析:因为例子中的 s0和s1中的”zhuge”都是字符串常量,它们在编译期就被确定了,所以s0==s1为true;而”zhu”和”ge”也都是字符串常量,当一个字 符串由多个字符串常量连接而成时,它自己肯定也是字符串常量,所以s2也同样在编译期就被优化为一个字符串常量\"zhuge\",所以s2也是常量池中” zhuge”的一个引用。所以我们得出s0==s1==s2;
示例2:String s0=\"zhuge\";String s1=new String(\"zhuge\");String s2=\"zhu\" + new String(\"ge\");System.out.println( s0==s1 ); // falseSystem.out.println( s0==s2 ); // falseSystem.out.println( s1==s2 ); // false分析:用new String() 创建的字符串不是常量,不能在编译期就确定,所以new String() 创建的字符串不放入常量池中,它们有自己的地址空间。s0还是常量池 中\"zhuge”的引用,s1因为无法在编译期确定,所以是运行时创建的新对象”zhuge”的引用,s2因为有后半部分 new String(”ge”)所以也无法在编译期确定,所以也是一个新创建对象”zhuge”的引用;明白了这些也就知道为何得出此结果了。
示例3: String a = \"a1\"; String b = \"a\" + 1; System.out.println(a == b); // true String a = \"atrue\"; String b = \"a\" + \"true\"; System.out.println(a == b); // true String a = \"a3.4\"; String b = \"a\" + 3.4; System.out.println(a == b); // true分析:JVM对于字符串常量的\"+\"号连接,将在程序编译期,JVM就将常量字符串的\"+\"连接优化为连接后的值,拿\"a\" + 1来说,经编译器优化后在class中就已经是a1。在编译期其字符串常量的值就确定下来,故上面程序最终的结果都为true。
示例4:String a = \"ab\";String bb = \"b\";String b = \"a\" + bb;System.out.println(a == b); // false分析:JVM对于字符串引用,由于在字符串的\"+\"连接中,有字符串引用存在,而引用的值在程序编译期是无法确定的,即\"a\" + bb无法被编译器优化,只有在程序运行期来动态分配并将连接后的新地址赋给b。所以上面程序的结果也就为false。
示例5:String a = \"ab\";final String bb = \"b\";String b = \"a\" + bb;System.out.println(a == b); // true分析:和示例4中唯一不同的是bb字符串加了final修饰,对于final修饰的变量,它在编译时被解析为常量值的一个本地拷贝存储到自己的常量池中或嵌入到它的字节码流中。所以此时的\"a\" + bb和\"a\" + \"b\"效果是一样的。故上面程序的结果为true。
示例6:String a = \"ab\";final String bb = getBB();String b = \"a\" + bb;System.out.println(a == b); // falseprivate static String getBB() { return \"b\"; }分析:JVM对于字符串引用bb,它的值在编译期无法确定,只有在程序运行期调用方法后,将方法的返回值和\"a\"来动态连接并分配地址为b,故上面 程序的结果为false。
关于String是不可变的 通过上面例子可以得出得知:String s = \"a\" + \"b\" + \"c\"; //就等价于String s = \"abc\";String a = \"a\";String b = \"b\";String c = \"c\";String s1 = a + b + c; s1 这个就不一样了,可以通过观察其JVM指令码发现s1的\"+\"操作会变成如下操作:StringBuilder temp = new StringBuilder();temp.append(a).append(b).append(c);String s = temp.toString();
最后再看一个例子://字符串常量池:\"计算机\"和\"技术\" 堆内存:str1引用的对象\"计算机技术\" //堆内存中还有个StringBuilder的对象,但是会被gc回收,StringBuilder的toString方法会new String(),这个String才是真正返回的对象引用String str2 = new StringBuilder(\"计算机\").append(\"技术\").toString(); //没有出现\"计算机技术\"字面量,所以不会在常量池里生成\"计算机技术\"对象System.out.println(str2 == str2.intern()); //true//\"计算机技术\" 在池中没有,但是在heap中存在,则intern时,会直接返回该heap中的引用//字符串常量池:\"ja\"和\"va\" 堆内存:str1引用的对象\"java\" //堆内存中还有个StringBuilder的对象,但是会被gc回收,StringBuilder的toString方法会new String(),这个String才是真正返回的对象引用String str1 = new StringBuilder(\"ja\").append(\"va\").toString(); //没有出现\"java\"字面量,所以不会在常量池里生成\"java\"对象System.out.println(str1 == str1.intern()); //false//java是关键字,在JVM初始化的相关类里肯定早就放进字符串常量池了String s1=new String(\"test\"); System.out.println(s1==s1.intern()); //false//\"test\"作为字面量,放入了池中,而new时s1指向的是heap中新生成的string对象,s1.intern()指向的是\"test\"字面量之前在池中生成的字符串对象String s2=new StringBuilder(\"abc\").toString();System.out.println(s2==s2.intern()); //false//同上
String常量池问题的几个例子
字符串常量池--------------------------------------------------------------------------------字符串常量池的设计思想字符串的分配,和其他的对象分配一样,耗费高昂的时间与空间代价,作为最基础的数据类型,大量频繁的创建字符串,极大程度地影响程序的性能JVM为了提高性能和减少内存开销,在实例化字符串常量的时候进行了一些优化为字符串开辟一个字符串常量池,类似于缓存区创建字符串常量时,首先查询字符串常量池是否存在该字符串存在该字符串,返回引用实例,不存在,实例化该字符串并放入池中
GC日志详解
JVM虚拟机
shardingJDBC定位为轻量级 Java 框架,在 Java 的 JDBC 层提供的额外服务。它使⽤客户端直连数据库,以 jar 包形式提供服务,⽆需额外部署和依赖,可理解为增强版的 JDBC 驱动,完全兼容 JDBC 和各种 ORM 框架。
ShardingProxy定位为透明化的数据库代理端,提供封装了数据库⼆进制协议的服务端版本,⽤于完成对异构语⾔的⽀持。⽬前提供 MySQL 和 PostgreSQL 版本,它可以使⽤任何兼容 MySQL/PostgreSQL 协议的访问客⼾端。
那这两种方式有什么区别呢? Sharding-JDBC Sharding-Proxy数据库 任意 MySQL/PostgreSQL连接消耗数 高 低异构语言 仅java 任意性能 损耗低 损耗略高无中心化 是 否静态入口 无 有很显然,ShardingJDBC只是客户端的一个工具包,可以理解为一个特殊的JDBC驱动包,所有分库分表逻辑均由业务方自己控制,所以他的功能相对灵活,支持的数据库也非常多,但是对业务侵入大,需要业务方自己定制所有的分库分表逻辑。而ShardingProxy是一个独立部署的服务,对业务方无侵入,业务方可以像用一个普通的MySQL服务一样进行数据交互,基本上感觉不到后端分库分表逻辑的存在,但是这也意味着功能会比较固定,能够支持的数据库也比较少。这两者各有优劣。
ShardingSphere包含三个重要的产品,ShardingJDBC、ShardingProxy和ShardingSidecar。其中sidecar是针对service mesh定位的一个分库分表插件,目前在规划中。而我们今天学习的重点是ShardingSphere的JDBC和Proxy这两个组件。 其中,ShardingJDBC是用来做客户端分库分表的产品,而ShardingProxy是用来做服务端分库分表的产品。这两者定位有什么区别呢?
ShardingJDBC是整个ShardingSphere最早也是最为核心的一个功能模块,他的主要功能就是数据分片和读写分离,通过ShardingJDBC,应用可以透明的使用JDBC访问已经分库分表、读写分离的多个数据源,而不用关心数据源的数量以及数据如何分布。
1、核心概念:逻辑表:水平拆分的数据库的相同逻辑和数据结构表的总称真实表:在分片的数据库中真实存在的物理表。数据节点:数据分片的最小单元。由数据源名称和数据表组成绑定表:分片规则一致的主表和子表。广播表:也叫公共表,指素有的分片数据源中都存在的表,表结构和表中的数据在每个数据库中都完全一致。例如字典表。分片键:用于分片的数据库字段,是将数据库(表)进行水平拆分的关键字段。SQL中若没有分片字段,将会执行全路由,性能会很差。分片算法:通过分片算法将数据进行分片,支持通过=、BETWEEN和IN分片。分片算法需要由应用开发者自行实现,可实现的灵活度非常高。分片策略:真正用于进行分片操作的是分片键+分片算法,也就是分片策略。在ShardingJDBC中一般采用基于Groovy表达式的inline分片策略,通过一个包含分片键的算法表达式来制定分片策略,如t_user_$->{u_id%8}标识根据u_id模8,分成8张表,表名称为t_user_0到t_user_7。
快速实战
NoneShardingStrategy不分片。这种严格来说不算是一种分片策略了。只是ShardingSphere也提供了这么一个配置。
InlineShardingStrategy最常用的分片方式配置参数: inline.shardingColumn 分片键;inline.algorithmExpression 分片表达式实现方式: 按照分片表达式来进行分片。
MyPreciseShardingAlgorithm
MyRangeShardingAlgorithm
StandardShardingStrategy只支持单分片键的标准分片策略。 配置参数:standard.sharding-column 分片键;standard.precise-algorithm-class-name 精确分片算法类名;standard.range-algorithm-class-name 范围分片算法类名 实现方式: shardingColumn指定分片算法。 preciseAlgorithmClassName 指向一个实现了io.shardingsphere.api.algorithm.sharding.standard.PreciseShardingAlgorithm接口的java类名,提供按照 = 或者 IN 逻辑的精确分片 示例:com.roy.shardingDemo.algorithm.MyPreciseShardingAlgorithm rangeAlgorithmClassName 指向一个实现了 io.shardingsphere.api.algorithm.sharding.standard.RangeShardingAlgorithm接口的java类名,提供按照Between 条件进行的范围分片。示例:com.roy.shardingDemo.algorithm.MyRangeShardingAlgorithm 说明: 其中精确分片算法是必须提供的,而范围分片算法则是可选的。
ComplexShardingStrategy支持多分片键的复杂分片策略。配置参数:complex.sharding-columns 分片键(多个); complex.algorithm-class-name 分片算法实现类。实现方式:shardingColumn指定多个分片列。algorithmClassName指向一个实现了org.apache.shardingsphere.api.sharding.complex.ComplexKeysShardingAlgorithm接口的java类名。提供按照多个分片列进行综合分片的算法。示例:com.roy.shardingDemo.algorithm.MyComplexKeysShardingAlgorithm
ShardingJDBC的整个实战完成后,可以看到,整个分库分表的核心就是在于配置的分片算法。我们的这些实战都是使用的inline分片算法,即提供一个分片键和一个分片表达式来制定分片算法。这种方式配置简单,功能灵活,是分库分表最佳的配置方式,并且对于绝大多数的分库分片场景来说,都已经非常好用了。但是,如果针对一些更为复杂的分片策略,例如多分片键、按范围分片等场景,inline分片算法就有点力不从心了。所以,我们还需要学习下ShardingSphere提供的其他几种分片策略。ShardingSphere目前提供了一共五种分片策略:
ShardingJDBC的分片算法
5、ShardingSphere的SQL使用限制
1、分库分表,其实围绕的都是一个核心问题,就是单机数据库容量的问题。我们要了解,在面对这个问题时,解决方案是很多的,并不止分库分表这一种。但是ShardingSphere的这种分库分表,是希望在软件层面对硬件资源进行管理,从而便于对数据库的横向扩展,这无疑是成本很小的一种方式。
2、一般情况下,如果单机数据库容量撑不住了,应先从缓存技术着手降低对数据库的访问压力。如果缓存使用过后,数据库访问量还是非常大,可以考虑数据库读写分离策略。如果数据库压力依然非常大,且业务数据持续增长无法估量,最后才考虑分库分表,单表拆分数据应控制在1000万以内。 当然,随着互联网技术的不断发展,处理海量数据的选择也越来越多。在实际进行系统设计时,最好是用MySQL数据库只用来存储关系性较强的热点数据,而对海量数据采取另外的一些分布式存储产品。例如PostGreSQL、VoltDB甚至HBase、Hive、ES等这些大数据组件来存储。
3、从上一部分ShardingJDBC的分片算法中我们可以看到,由于SQL语句的功能实在太多太全面了,所以分库分表后,对SQL语句的支持,其实是步步为艰的,稍不小心,就会造成SQL语句不支持、业务数据混乱等很多很多问题。所以,实际使用时,我们会建议这个分库分表,能不用就尽量不要用。 如果要使用优先在OLTP场景下使用,优先解决大量数据下的查询速度问题。而在OLAP场景中,通常涉及到非常多复杂的SQL,分库分表的限制就会更加明显。当然,这也是ShardingSphere以后改进的一个方向。
4、如果确定要使用分库分表,就应该在系统设计之初开始对业务数据的耦合程度和使用情况进行考量,尽量控制业务SQL语句的使用范围,将数据库往简单的增删改查的数据存储层方向进行弱化。并首先详细规划垂直拆分的策略,使数据层架构清晰明了。而至于水平拆分,会给后期带来非常非常多的数据问题,所以应该谨慎、谨慎再谨慎。一般也就在日志表、操作记录表等很少的一些边缘场景才偶尔用用。
分库分表带来的问题
ShardingJDBC实战
ShardingSphere虽然有多个产品,但是他们的数据分片主要流程是完全一致的。
为了便于理解,抽象语法树中的关键字的 Token 用绿色表示,变量的 Token 用红色表示,灰色表示需要进⼀步拆分。通过对抽象语法树的遍历,可以标记出所有可能需要改写的位置。SQL的一次解析过程是不可逆的,所有token按SQL原本的顺序依次进行解析,性能很高。并且在解析过程中,需要考虑各种数据库SQL方言的异同,提供不同的解析模版。其中,SQL解析是整个分库分表产品的核心,其性能和兼容性是最重要的衡量指标。ShardingSphere在1.4.x之前采用的是性能较快的Druid作为SQL解析器。1.5.x版本后,采用自研的SQL解析器,针对分库分表场景,采取对SQL半理解的方式,提高SQL解析的性能和兼容性。然后从3.0.x版本后,开始使用ANLTR作为SQL解析引擎。这是个开源的SQL解析引擎,ShardingSphere在使用ANLTR时,还增加了一些AST的缓存功能。针对ANLTR4的特性,官网建议尽量采用PreparedStatement的预编译方式来提高SQL执行的性能。
sql解析整体结构:
解析引擎
全库表路由:对于不带分片键的DQL、DML以及DDL语句,会遍历所有的库表,逐一执行。例如 select * from course 或者 select * from course where ustatus='1'(不带分片键)全库路由:对数据库的操作都会遍历所有真实库。 例如 set autocommit=0全实例路由:对于DCL语句,每个数据库实例只执行一次,例如 CREATE USER customer@127.0.0.1 identified BY '123';单播路由:仅需要从任意库中获取数据即可。 例如 DESCRIBE course阻断路由:屏蔽SQL对数据库的操作。例如 USE coursedb。就不会在真实库中执行,因为针对虚拟表操作,不需要切换数据库。
根据解析上下文匹配数据库和表的分片策略,生成路由路径。ShardingSphere的分片策略主要分为单片路由(分片键的操作符是等号)、多片路由(分片键的操作符是IN)和范围路由(分片键的操作符是Between)。不携带分片键的SQL则是广播路由。分片策略通常可以由数据库内置也可以由用户方配置。内置的分片策略大致可分为尾数取模、哈希、范围、标签、时间等。 由用户方配置的分片策略则更加灵活,可以根据使用方需求定制复合分片策略。实际使用时,应尽量使用分片路由,明确路由策略。因为广播路由影响过大,不利于集群管理及扩展。
路由引擎
用户只需要面向逻辑库和逻辑表来写SQL,最终由ShardigSphere的改写引擎将SQL改写为在真实数据库中可以正确执行的语句。SQL改写分为正确性改写和优化改写。
改写引擎
ShardingSphere引入了连接模式的概念,分为内存限制模式(MEMORY_STRICTLY)和连接限制模式(CONNECTION_STRICTLY)。这两个模式的区分涉及到一个参数 spring.shardingsphere.props.max.connections.size.per.query=50(默认值1,配置参见源码中ConfigurationPropertyKey类)。ShardingSphere会根据 路由到某一个数据源的路由结果 计算出 所有需在数据库上执行的SQL数量,用这个数量除以 用户的配置项,得到每个数据库连接需执行的SQL数量。数量>1就会选择连接限制模式,数量<=1就会选择内存限制模式。内存限制模式不限制连接数,也就是说会建立多个数据连接,然后并发控制每个连接只去读取一个数据分片的数据。这样可以最快速度的把所有需要的数据读出来。并且在后面的归并阶段,会选择以每一条数据为单位进行归并,就是后面提到的流式归并。这种归并方式归并完一批数据后,可以释放内存了,可以很好的提高数据归并的效率,并且防止出现内存溢出或垃圾回收频繁的情况。他的吞吐量比较大,比较适合OLAP场景。连接限制模式会对连接数进行限制,也即是说至少有一个数据库连接会要去读取多个数据分片的数据。这样他会对这个数据库连接采用串行的方式依次读取多个数据分片的数据。而这种方式下,会将数据全部读入到内存,进行统一的数据归并,也就是后面提到的内存归并。这种方式归并效率会比较高,例如一个MAX归并,直接就能拿到最大值,而流式归并就需要一条条的比较。比较适合OLTP场景。
ShardingSphere并不是简单的将改写完的SQL提交到数据库执行。执行引擎的目标是自动化的平衡资源控制和执行效率。例如他的连接模式分为内存限制模式(MEMORY_STRICTLY)和连接限制模式(CONNECTION_STRICTLY)。内存限制模式只关注一个数据库连接的处理数量,通常一张真实表一个数据库连接。而连接限制模式则只关注数据库连接的数量,较大的查询会进行串行操作。
执行引擎
例如: AVG归并就无法直接进行分片归并,需要转化成COUNT&SUM的累加归并,然后再计算平均值。排序归并的流程如下图:
将从各个数据节点获取的多数据结果集,组合成为一个结果集并正确的返回至请求客户端,称为结果归并。其中,流式归并是指以一条一条数据的方式进行归并,而内存归并是将所有结果集都查询到内存中,进行统一归并。
UUID 采用UUID.randomUUID()的方式产生唯一且不重复的分布式主键。最终生成一个字符串类型的主键。缺点是生成的主键无序。
优点:毫秒数在高位,自增序列在低位,整个ID都是趋势递增的。不依赖第三方组件,稳定性高,生成ID的性能也非常高。可以根据自身业务特性分配bit位,非常灵活缺点:强依赖机器时钟,如果机器上时钟回拨,会导致发号重复。
分布式主键内置生成器支持:UUID、SNOWFLAKE,并抽离出分布式主键生成器的接口,方便用户自行实现自定义的自增主键生成器。
归并引擎
内核剖析
内核原理
我们需要到conf目录下,修改server.yaml,将配置文件中的authentication和props两段配置的注释打开。
authentication: users: root: password: root sharding: password: sharding authorizedSchemas: sharding_dbprops: max.connections.size.per.query: 1 acceptor.size: 16 # The default value is available processors count * 2. executor.size: 16 # Infinite by default. proxy.frontend.flush.threshold: 128 # The default value is 128. # LOCAL: Proxy will run with LOCAL transaction. # XA: Proxy will run with XA transaction. # BASE: Proxy will run with B.A.S.E transaction. proxy.transaction.type: LOCAL proxy.opentracing.enabled: false proxy.hint.enabled: false query.with.cipher.column: true sql.show: false allow.range.query.with.inline.sharding: false
然后,我们修改conf目录下的config-sharding.yaml,这个配置文件就是shardingProxy关于分库分表部分的配置。整个配置和之前我们使用ShardingJDBC时的配置大致相同,我们在最下面按照自己的数据库环境增加以下配置:
schemaName: sharding_dbdataSources: m1: url: jdbc:mysql://localhost:3306/userdb?serverTimezone=GMT%2B8&useSSL=false username: root password: root connectionTimeoutMilliseconds: 30000 idleTimeoutMilliseconds: 60000 maxLifetimeMilliseconds: 1800000 maxPoolSize: 50shardingRule: tables: course: actualDataNodes: m1.course_$->{1..2} tableStrategy: inline: shardingColumn: cid algorithmExpression: course_$->{cid%2+1} keyGenerator: type: SNOWFLAKE column: cid
1、ShardingProxy部署
ShardingProxy使用这样,我们就可以像连接一个标准MySQL服务一样连接ShardingProxy了。
从ShardingProxy的server.yaml中看到,ShardingProxy还支持非常多的服务治理功能。在server.yaml配置文件中的orchestration部分属性就演示了如何将ShardingProxy注册到Zookeeper当中。
ShardingSphere在服务治理这一块主要有两个部分: 一是数据接入以及弹性伸缩。简单理解就是把MySQL或者其他数据源的数据快速迁移进ShardingSphere的分片库中。并且能够快速的对已有的ShardingShere分片库进行扩容以及减配。这一块由ShardingSphere-scaling产品来提供支持。只是这个功能在目前的4.1.1版本中,还处于Alpha测试阶段。 另一方面,ShardingSphere支持将复杂的分库分表配置上传到统一的注册中心中集中管理。目前支持的注册中心有Zookeeper和Etcd。而ShardingSphere也提供了SPI扩展接口,可以快速接入Nacos、Apollo等注册中心。在ShardingProxy的server.yaml中我们已经看到了这一部分的配置示例。 另外,ShardingSphere针对他的这些生态功能,提供了一个ShardingSphere-UI产品来提供页面支持。ShardingSphere-UI是针对整个ShardingSphere的一个简单有用的Web管理控制台。它用于帮助用户更简单的使用ShardingSphere的相关功能。目前提供注册中心管理、动态配置管理、数据库编排管理等功能。
ShardingProxy的服务治理
影子库 这部分功能主要是用于进行压测的。通过给生产环境上的关键数据库表配置一个影子库,就可以将写往生产环境的数据全部转为写入影子库中,而影子库通常会配置成跟生产环境在同一个库,这样就可以在生产环境上直接进行压力测试,而不会影响生产环境的数据。 在conf/config-shadow.yaml中有配置影子库的示例。其中最核心的就是下面的shadowRule这一部分。#shadowRule:# column: shadow# shadowMappings:# 绑定shadow_ds为ds的影子库# ds: shadow_ds 数据加密 在conf/config-encrypt.yaml中还演示了ShardingProxy的另一个功能,数据加密。默认集成了AES对称加密和MD5加密。还可以通过SPI机制自行扩展更多的加密算法。
Shardingproxy的其他功能
ShardingProxy快速使用
我们现在已经学完了ShardingSphere除了Sharding-SideCar以外的所有产品了,整个sharding + proxy的所有这些功能,本质上其实都只解决了一个问题,就是单机数据库容量的问题。在软件层面对硬件资源进行管理,从而便于对数据库的横向扩展。 但是,我们也要意识到他带来的很多问题。 例如对业务的侵入大。业务系统写的SQL将不再是纯粹的能在服务器上运行的SQL了,对大量跨维度的JOIN、聚合、子查询、排序等功能在业务上很难进行验证。这必然会弱化数据库的功能。 并且,使用ShardingSphere管理后,数据库之间变成了结合非常紧密的依赖关系,对整个集群的扩容也会带来相当大的难度。 另外,ShardingSphere这种方式实际上将原本由业务管理SQL的工作方式,转化成了由业务管理逻辑SQL,而运维管理实际SQL的混合工作模式,再加上一大堆服务的引入,整个服务运维的维护工作量以及工作难度也上升了非常多。 当然,相信随着ShardingSphere后续版本的不断升级优化,这些问题都会得到不同程度的改善
ShardingSphere总结
LOCAL本地事务 本地事务方式也就是使用Spring的@Transaction注解来进行配置。传统的本地事务是不具备分布式事务特性的,但是ShardingSphere对本地事务进行了增强。在ShardingSphere中,LOCAL本地事务已经完全支持由于逻辑异常导致的分布式事务问题。不过这种本地事务模式IBU支持因网络、硬件导致的跨库事务。例如同一个事务中,跨两个库更新,更新完毕后,提交之前,第一个库宕机了,则只有第二个库数据提交。
1、引入maven依赖<dependency> <groupId>org.apache.shardingsphere</groupId> <artifactId>sharding-jdbc-core</artifactId> <version>${sharding-sphere.version}</version></dependency><!-- 使用XA事务时,需要引入此模块 --><dependency> <groupId>org.apache.shardingsphere</groupId> <artifactId>sharding-transaction-xa-core</artifactId> <version>${shardingsphere.version}</version></dependency><!-- 使用XA事务时,可以引入其他几种事务管理器 --><dependency> <groupId>org.apache.shardingsphere</groupId> <artifactId>shardingsphere-transaction-xa-bitronix</artifactId></dependency><dependency> <groupId>org.apache.shardingsphere</groupId> <artifactId>shardingsphere-transaction-xa-narayana</artifactId></dependency>
2、配置事务管理器@Configuration@EnableTransactionManagementpublic class TransactionConfiguration { @Bean public PlatformTransactionManager txManager(final DataSource dataSource) { return new DataSourceTransactionManager(dataSource); } //如果不使用jdbctemplate就可以不注入。 @Bean public JdbcTemplate jdbcTemplate(final DataSource dataSource) { return new JdbcTemplate(dataSource); }}
测试案例 我们可以使用第二节中的application01.properties案例来进行简单的测试。 在application01.properties中,配置了逻辑表course的两个实际表course_1和course_2。当执行下面的测试案例时,会将两种表的user_id都一起进行更新。 @Test public void updateCourse(){ Course c = new Course(); UpdateWrapper<Course> wrapper = new UpdateWrapper<>(); wrapper.set(\"user_id\
XA事务快速上手 这种模式下,是由ShardingJDBC所在的应用来作为事务协调者,通过XA方式来协调分布到多个数据库中的分库分表语句的分布式事务。 在ShardingJDBC的官方文档中,有对分布式事务的几个示例,可以用来参考下:https://shardingsphere.apache.org/document/legacy/4.x/document/cn/manual/sharding-jdbc/usage/transaction/
接下来修改seata-Server的解压目录下的conf/registry.conf文件,配置seata的注册中心。registry { # file 、nacos 、eureka、redis、zk、consul、etcd3、sofa type = \"nacos\" loadBalance = \"RandomLoadBalance\" loadBalanceVirtualNodes = 10 nacos { application = \"seata-server\" serverAddr = \"192.168.65.232:8848\" namespace = \"public\" group = \"SEATA_GROUP\" cluster = \"default\" #username = \"nacos\" #password = \"nacos\" }}config { # file、nacos 、apollo、zk、consul、etcd3 type = \"nacos\" nacos { application = \"seata-server\" serverAddr = \"192.168.65.232:8848\" namespace = \"29ccf18e-e559-4a01-b5d4-61bad4a89ffd\" group = \"SEATA_GROUP\" cluster = \"default\" username = \"nacos\" password = \"nacos\" }}
这样就可以启动seata了。 启动成功后,可以在Nacos控制台上看到 服务名=serverAddr服务注册列表sh seata-server.sh -p $LISTEN_PORT -m $STORE_MODE -h $IP(此参数可选)
最后给nacos发送一个put请求,定制参数curl -X PUT 'localhost:8848/nacos/v1/ns/operator/switches?entry=serverMode&value=AP'
seata部署方式: nacos: 下载压缩包,解压执行bin目录下的startup指令即可。Demo中是使用的1.4.1版本--以独立方式启动sh startup.sh -m standalone seata:同样是下载发布包,并解压。Demo中使用1.4.0版本 然后往nacos上初始化配置,这个脚本会在nacos上注册一组 Group=SEATA_GROUP 的配置项。sh nacos-config.sh localhost
使用BASE柔性事务需要引入maven依赖<!-- 使用BASE事务时,需要引入此模块 --><dependency> <groupId>org.apache.shardingsphere</groupId> <artifactId>sharding-transaction-base-seata-at</artifactId> <version>${sharding-sphere.version}</version></dependency><dependency> <groupId>io.seata</groupId> <artifactId>seata-all</artifactId> <version>1.4.0</version></dependency><dependency> <groupId>com.alibaba.nacos</groupId> <artifactId>nacos-client</artifactId> <version>1.4.1</version></dependency>
注意seata下的事务组配置: service.vgroupMapping.my_test_tx_group = default,其中这个my_test_tx_group 就是配置的事务组。这个事务组相当于是一个多租户的概念,不同的事务组之间的配置信息是隔离的。然后后面的default对应的是Seata中的TC集群名。默认就是default。 而这个TC集群中有哪些服务节点是要另外配置的。 service.default.gouplist = 127.0.0.1:8091 这个配置中就配置了default这个集群中对应的节点列表。这些节点就会加入到同一个分布式事务中。
然后,还需要将服务端的registry.conf文件也复制到classpath目录下。也就是需要与服务端匹配。 最后使用的方式和XA基本是一样的,在声明@ShardingTransactionType注解时声明成BASE类型的就可以了。 Demo中提供了JUnit测试案例:TransactionTest柔性事务使用的难点还是在seata上。用起来要非常小心。
客户端使用Base事务
BASE柔性事务快速上手这种模式,是由Seata作为事务协调者,来进行协调。使用方式需要先部署seata服务。官方建议是使用seata配合nacos作为配置中心来使用。实际上是使用的seata的AT模式进行两阶段提交。
快速上手
XA事务中,事务都是有状态控制的,例如如果对于一个ACTIVE状态的事务进行COMMIT提交,mysql就会抛出异常ERROR 1399 (XAE07): XAER_RMFAIL: The command cannot be executed when global transaction is in the ACTIVE state
XA事务XA是由X/Open组织提出的分布式事务的规范。 主流的关系型 数据库产品都是实现了XA接口的。 例如在MySQL从5.0.3版本开始,就已经可以直接支持XA事务了,但是要注意只有InnoDB引擎才提供支持。
Base柔性事务 柔性事务是指 Basic Available(基本可用)、Soft-state(软状态/柔性事务)、Eventual Consistency(最终一致性)。他的核心思想是既然无法保证分布式事务每时每刻的强一致性,那就根据每个业务自身的特点,采用合适的方式来使系统达到最终一致性。这里所谓强一致性,就是指在任何时刻,分布式事务的各个参与方的事务状态都是对齐的。典型的强一致性场景就是操作系统的文件系统。不管有多少个软件操作同一个文件,文件的状态始终是一致的。
二、分布式事务原理详解
ShardingProxy与ShardingJDBC系出同门,接入分布式API的方式基本是一致的。同样支持LOCAL、XA、BASE类型的事务。关于分布式事务的配置, 是由server.yaml中配置的属性props:proxy.transaction.type: LOCAL指定的, 默认是LOCAL。 如果要使用XA事务,将这个属性调整为XA即可。ShardingProxy默认就支持XA事务,默认的事务管理器是Atomikos。 其中,ShardingProxy默认就支持XA事务,默认的事务管理器是Atomikos。不用做任何配置,默认就会使用。可以试试在ShardingProxy中执行XA事务的相关语句。
分布式事务示例
分布式事务
shardingJDBC
MQ:MessageQueue,消息队列。 队列,是一种FIFO 先进先出的数据结构。消息由生产者发送到MQ进行排队,然后按原来的顺序交由消息的消费者进行处理。QQ和微信就是典型的MQ。MQ的作用主要有以下三个方面:异步例子:快递员发快递,直接到客户家效率会很低。引入菜鸟驿站后,快递员只需要把快递放到菜鸟驿站,就可以继续发其他快递去了。客户再按自己的时间安排去菜鸟驿站取快递。作用:异步能提高系统的响应速度、吞吐量。解耦例子:《Thinking in JAVA》很经典,但是都是英文,我们看不懂,所以需要编辑社,将文章翻译成其他语言,这样就可以完成英语与其他语言的交流。作用:1、服务之间进行解耦,才可以减少服务之间的影响。提高系统整体的稳定性以及可扩展性。2、另外,解耦后可以实现数据分发。生产者发送一个消息后,可以由一个或者多个消费者进行消费,并且消费者的增加或者减少对生产者没有影响。削峰例子:长江每年都会涨水,但是下游出水口的速度是基本稳定的,所以会涨水。引入三峡大坝后,可以把水储存起来,下游慢慢排水。作用:以稳定的系统资源应对突发的流量冲击。
2、MQ的优缺点 上面MQ的所用也就是使用MQ的优点。 但是引入MQ也是有他的缺点的:系统可用性降低系统引入的外部依赖增多,系统的稳定性就会变差。一旦MQ宕机,对业务会产生影响。这就需要考虑如何保证MQ的高可用。系统复杂度提高引入MQ后系统的复杂度会大大提高。以前服务之间可以进行同步的服务调用,引入MQ后,会变为异步调用,数据的链路就会变得更复杂。并且还会带来其他一些问题。比如:如何保证消费不会丢失?不会被重复调用?怎么保证消息的顺序性等问题。消息一致性问题A系统处理完业务,通过MQ发送消息给B、C系统进行后续的业务处理。如果B系统处理成功,C系统处理失败怎么办?这就需要考虑如何保证消息数据处理的一致性。
3、几大MQ产品特点比较 常用的MQ产品包括Kafka、RabbitMQ和RocketMQ。我们对这三个产品做下简单的比较,重点需要理解他们的适用场景。image另外,关于这三大产品更详细的比较,可以参见《kafka vs rabbitmq vs rocketmq.pdf》关于RabbitMQ的功能特性,可以在官网( https://www.rabbitmq.com/ )上看到,包含 Asynchronous Message(异步消息)、Developer Experience(开发体验)、Distributed Deployment(分布式部署)、Enterprise & Cloud Ready(企业云部署)、Tools & Plugins(工具和插件)、Management & Monitoring(管理和监控)六大部分。所以其中的功能是相当丰富的,而我们肯定只能关注重点的部分内容,所以还是要经常到官网上去看看的。
mq介绍
默认的普通集群模式:这种模式使用Erlang语言天生具备的集群方式搭建。这种集群模式下,集群的各个节点之间只会有相同的元数据,即队列结构,而消息不会进行冗余,只存在一个节点中。消费时,如果消费的不是存有数据的节点, RabbitMQ会临时在节点之间进行数据传输,将消息从存有数据的节点传输到消费的节点。很显然,这种集群模式的消息可靠性不是很高。因为如果其中有个节点服务宕机了,那这个节点上的数据就无法消费了,需要等到这个节点服务恢复后才能消费,而这时,消费者端已经消费过的消息就有可能给不了服务端正确应答,服务起来后,就会再次消费这些消息,造成这部分消息重复消费。 另外,如果消息没有做持久化,重启就消息就会丢失。并且,这种集群模式也不支持高可用,即当某一个节点服务挂了后,需要手动重启服务,才能保证这一部分消息能正常消费。所以这种集群模式只适合一些对消息安全性不是很高的场景。而在使用这种模式时,消费者应该尽量的连接上每一个节点,减少消息在集群中的传输。
镜像模式:这种模式是在普通集群模式基础上的一种增强方案,这也就是RabbitMQ的官方HA高可用方案。需要在搭建了普通集群之后再补充搭建。其本质区别在于,这种模式会在镜像节点中间主动进行消息同步,而不是在客户端拉取消息时临时同步。并且在集群内部有一个算法会选举产生master和slave,当一个master挂了后,也会自动选出一个来。从而给整个集群提供高可用能力。这种模式的消息可靠性更高,因为每个节点上都存着全量的消息。而他的弊端也是明显的,集群内部的网络带宽会被这种同步通讯大量的消耗,进而降低整个集群的性能。这种模式下,队列数量最好不要过多。
对于RabbitMQ同样,通过集群模式,能够增大他的吞吐量,但是,针对单个队列,如何增加吞吐量呢?普通集群保证不了数据的高可用,而镜像队列虽然可以保证消息高可用,但是消费者并不能对消息增加消费并发度,所以,RabbitMQ的集群机制并不能增加单个队列的吞吐量。 上面的懒队列其实就是针对这个问题的一种解决方案。但是很显然,懒队列的方式属于治标不治本。真正要提升RabbitMQ单队列的吞吐量,还是要从数据也就是消息入手,只有将数据真正的分开存储才行。RabbitMQ提供的Sharding插件,就是一个可选的方案。他会真正将一个队列中的消息分散存储到不同的节点上,并提供多个节点的负载均衡策略实现对等的读与写功能。
消息分片存储插件-Sharding Plugin
RabbitMQ集群搭建
可以参照下图来理解RabbitMQ当中的基础概念
这个在之前搭建时已经体验过了。RabbitMQ出于服务器复用的想法,可以在一个RabbitMQ集群中划分出多个虚拟主机,每一个虚拟主机都有AMQP的全套基础组件,并且可以针对每个虚拟主机进行权限以及数据分配,并且不同虚拟主机之间是完全隔离的。
虚拟主机 virtual host
客户端与RabbitMQ进行交互,首先就需要建立一个TPC连接,这个连接就是Connection。
连接 Connection
一旦客户端与RabbitMQ建立了连接,就会分配一个AMQP信道 Channel。每个信道都会被分配一个唯一的ID。也可以理解为是客户端与RabbitMQ实际进行数据交互的通道,我们后续的大多数的数据操作都是在信道 Channel 这个层面展开的。 RabbitMQ为了减少性能开销,也会在一个Connection中建立多个Channel,这样便于客户端进行多线程连接,这些连接会复用同一个Connection的TCP通道,所以在实际业务中,对于Connection和Channel的分配也需要根据实际情况进行考量。
信道 Channel
这是RabbitMQ中进行数据路由的重要组件。消息发送到RabbitMQ中后,会首先进入一个交换机,然后由交换机负责将数据转发到不同的队列中。RabbitMQ中有多种不同类型的交换机来支持不同的路由策略。从Web管理界面就能看到,在每个虚拟主机中,RabbitMQ都会默认创建几个不同类型的交换机来。
交换机 Exchange
队列是实际保存数据的最小单位。队列结构天生就具有FIFO的顺序,消息最终都会被分发到不同的队列当中,然后才被消费者进行消费处理。这也是最近RabbitMQ功能变动最大的地方。最为常用的是经典队列Classic。RabbitMQ 3.8.X版本添加了Quorum队列,3.9.X又添加了Stream队列。
这是RabbitMQ最为经典的队列类型。在单机环境中,拥有比较高的消息可靠性。在这个图中可以看到,经典队列可以选择是否持久化(Durability)以及是否自动删除(Auto delete)两个属性。其中,Durability有两个选项,Durable和Transient。 Durable表示队列会将消息保存到硬盘,这样消息的安全性更高。但是同时,由于需要有更多的IO操作,所以生产和消费消息的性能,相比Transient会比较低。Auto delete属性如果选择为是,那队列将在至少一个消费者已经连接,然后所有的消费者都断开连接后删除自己。
Classic 经典队列
仲裁队列,是RabbitMQ从3.8.0版本,引入的一个新的队列类型,整个3.8.X版本,也都是在围绕仲裁队列进行完善和优化。仲裁队列相比Classic经典队列,在分布式环境下对消息的可靠性保障更高。官方文档中表示,未来会使用Quorum仲裁队列代替传统Classic队列。Quorum是基于Raft一致性协议实现的一种新型的分布式消息队列,他实现了持久化,多备份的FIFO队列,主要就是针对RabbitMQ的镜像模式设计的。简单理解就是quorum队列中的消息需要有集群中多半节点同意确认后,才会写入到队列中。这种队列类似于RocketMQ当中的DLedger集群。这种方式可以保证消息在集群内部不会丢失。同时,Quorum是以牺牲很多高级队列特性为代价,来进一步保证消息在分布式环境下的高可靠。
**Quorum队列更适合于 队列长期存在,并且对容错、数据安全方面的要求比低延迟、不持久等高级队列更能要求更严格的场景。**例如 电商系统的订单,引入MQ后,处理速度可以慢一点,但是订单不能丢失。也对应以下一些不适合使用的场景:1、一些临时使用的队列:比如transient临时队列,exclusive独占队列,或者经常会修改和删除的队列。2、对消息低延迟要求高: 一致性算法会影响消息的延迟。3、对数据安全性要求不高:Quorum队列需要消费者手动通知或者生产者手动确认。4、队列消息积压严重 : 如果队列中的消息很大,或者积压的消息很多,就不要使用Quorum队列。Quorum队列当前会将所有消息始终保存在内存中,直到达到内存使用极限。
Quorum 仲裁队列
Stream队列是RabbitMQ自3.9.0版本开始引入的一种新的数据队列类型,也是目前官方最为推荐的队列类型。这种队列类型的消息是持久化到磁盘并且具备分布式备份的,更适合于消费者多,读消息非常频繁的场景。
Stream队列的核心是以append-only只添加的日志来记录消息,整体来说,就是消息将以append-only的方式持久化到日志文件中,然后通过调整每个消费者的消费进度offset,来实现消息的多次分发。下方有几个属性也都是来定义日志文件的大小以及保存时间。如果你熟悉Kafka或者RocketMQ,会对这种日志记录消息的方式非常熟悉。这种队列提供了RabbitMQ已有的其他队列类型不太好实现的四个特点:1、large fan-outs 大规模分发 当想要向多个订阅者发送相同的消息时,以往的队列类型必须为每个消费者绑定一个专用的队列。如果消费者的数量很大,这就会导致性能低下。而Stream队列允许任意数量的消费者使用同一个队列的消息,从而消除绑定多个队列的需求。2、Replay/Time-travelling 消息回溯 RabbitMQ已有的这些队列类型,在消费者处理完消息后,消息都会从队列中删除,因此,无法重新读取已经消费过的消息。而Stream队列允许用户在日志的任何一个连接点开始重新读取数据。3、Throughput Performance 高吞吐性能 Strem队列的设计以性能为主要目标,对消息传递吞吐量的提升非常明显。4、Large logs 大日志 RabbitMQ一直以来有一个让人诟病的地方,就是当队列中积累的消息过多时,性能下降会非常明显。但是Stream队列的设计目标就是以最小的内存开销高效地存储大量的数据。 整体上来说,RabbitMQ的Stream队列,其实有很多地方借鉴了其他MQ产品的优点,在保证消息可靠性的基础上,着力提高队列的消息吞吐量以及消息转发性能。因此,Stream也是在视图解决一个RabbitMQ一直以来,让人诟病的缺点,就是当队列中积累的消息过多时,性能下降会非常明显的问题。RabbitMQ以往更专注于企业级的内部使用,但是从这些队列功能可以看到,Rabbitmq也在向更复杂的互联网环境靠拢,未来对于RabbitMQ的了解,也需要随着版本推进,不断更新。 但是,从整体功能上来讲,队列只不过是一个实现FIFO的数据结构而已,这种数据结构其实是越简单越好。而当前RabbitMQ区分出这么多种队列类型,其实极大的增加了应用层面的使用难度,应用层面必须有一些不同的机制兼容各种队列。所以,在未来版本中,RabbitMQ很可能还是会将这几种队列类型最终统一成一种类型。例如官方已经说明未来会使用Quorum队列类型替代经典队列,到那时,应用层很多工具就可以得到简化,比如不需要再设置durable和exclusive属性。虽然Quorum队列和Stream队列目前还没有合并的打算,但是在应用层面来看,他们两者是冲突的,是一种竞争关系,未来也很有可能最终统一保留成一种类型。至于未来走向如何,我们可以在后续版本拭目以待。
Stream队列
RabbitMQ从3.6.0版本开始,就引入了懒队列的概念。懒队列会尽可能早的将消息内容保存到硬盘当中,并且只有在用户请求到时,才临时从硬盘加载到RAM内存当中。 懒队列的设计目标是为了支持非常长的队列(数百万级别)。队列可能会因为一些原因变得非常长-也就是数据堆积。消费者服务宕机了有一个突然的消息高峰,生产者生产消息超过消费者消费者消费太慢了 默认情况下,RabbitMQ接收到消息时,会保存到内存以便使用,同时把消息写到硬盘。但是,消息写入硬盘的过程中,是会阻塞队列的。RabbitMQ虽然针对写入硬盘速度做了很多算法优化,但是在长队列中,依然表现不是很理想,所以就有了懒队列的出现。 懒队列会尝试尽可能早的把消息写到硬盘中。这意味着在正常操作的大多数情况下,RAM中要保存的消息要少得多。当然,这是以增加磁盘IO为代价的。
懒队列 Lazy Queue
队列 Queue
RabbitMQ基础概念
5.1.1、maven依赖<dependency> <groupId>com.rabbitmq</groupId> <artifactId>amqp-client</artifactId> <version>5.9.0</version></dependency>
step1、首先创建连接,获取ChannelConnectionFactory factory = new ConnectionFactory();factory.setHost(\"localhost\");connection = factory.newConnection();channel = connection.createChannel();
注意:1、同样,durable参数必须是true,exclusive必须是false。 --你应该会想到,对于这两种队列,这两个参数就是多余的了,未来可以直接删除。2、x-max-length-bytes 表示日志文件的最大字节数。x-stream-max-segment-size-bytes 每一个日志文件的最大大小。这两个是可选参数,通常为了防止stream日志无限制累计,都会配合stream队列一起声明。
其中autoAck是个关键。autoAck为true则表示消息发送到该Consumer后就被Consumer消费掉了,不需要再往其他Consumer转发。为false则会继续往其他Consumer转发。要注意如果每个Consumer一直为false,会导致消息不停的被转发,不停的吞噬系统资源,最终造成宕机。
3.Stream队列消费 在当前版本下,消费Stream队列时,需要注意三板斧的设置。channel必须设置basicQos属性。正确声明Stream队列。消费时需要指定offset。
step4、Consumer消费消息定义消费者,消费消息进行处理,并向RabbitMQ进行消息确认。确认了之后就表明这个消息已经消费完了,否则RabbitMQ还会继续让别的消费者实例来处理。主要收集了两种消费方式
step5、完成以后关闭连接,释放资源 channel.close();
5.1.2、基础编程模型 这些各种各样的消息模型其实都对应一个比较统一的基础编程模型。
最直接的方式,P端发送一个消息到一个指定的queue,中间不需要任何exchange规则。C端按queue方式进行消费。关键代码:(其实关键的区别也就是几个声明上的不同。)
hello world体验
工作任务模式,领导部署一个任务,由下面的一个员工来处理。
Work queues 工作序列
type为fanout 的exchange:image这个机制是对上面的一种补充。也就是把preducer与Consumer进行进一步的解耦。producer只负责发送消息,至于消息进入哪个queue,由exchange来分配。如上图,就是把producer发送的消息,交由exchange同时发送到两个queue里,然后由不同的Consumer去进行消费。
Publish/Subscribe 订阅 发布 机制
type为”direct” 的exchangeimage这种模式一看图就清晰了。 在上一章 exchange 往所有队列发送消息的基础上,增加一个路由配置,指定exchange如何将不同类别的消息分发到不同的queue上。
Routing 基于内容的路由
type为\"topic\
Topics 话题
1、发布单条消息 即发布一条消息就确认一条消息。核心代码:for (int i = 0; i < MESSAGE_COUNT; i++) { String body = String.valueOf(i); channel.basicPublish(\"\
发送批量消息 之前单条确认的机制会对系统的吞吐量造成很大的影响,所以稍微中和一点的方式就是发送一批消息后,再一起确认。 核心代码: int batchSize = 100; int outstandingMessageCount = 0; long start = System.nanoTime(); for (int i = 0; i < MESSAGE_COUNT; i++) { String body = String.valueOf(i); ch.basicPublish(\"\
RabbitMQ的消息可靠性是非常高的,但是他以往的机制都是保证消息发送到了MQ之后,可以推送到消费者消费,不会丢失消息。但是发送者发送消息是否成功是没有保证的。我们可以回顾下,发送者发送消息的基础API:Producer.basicPublish方法是没有返回值的,也就是说,一次发送消息是否成功,应用是不知道的,这在业务上就容易造成消息丢失。而这个模块就是通过给发送者提供一些确认机制,来保证这个消息发送的过程是成功的。发送者确认模式默认是不开启的,所以如果需要开启发送者确认模式,需要手动在channel中进行声明。channel.confirmSelect(); 在官网的示例中,重点解释了三种策略:
Publisher Confirms 发送者消息确认
5.1.3、官网的消息场景
原生API
5.2.1:引入依赖 SpringBoot官方集成了RabbitMQ,只需要快速引入依赖包即可使用。RabbitMQ与SpringBoot集成的核心maven依赖就下面一个。<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-amqp</artifactId></dependency>
5.2.2: 配置生产者 基础的运行环境参数以及生产者的一些默认属性配置都集中到了application.properties配置文件中。所有配置项都以spring.rabbitmq开头。关于详细的配置信息,可以参见RabbitProperties类的源码,源码中有各个字段的简单说明。如果需要更详细的配置资料,那就需要去官方的github仓库上去查了。github地址: https://github.com/spring-projects/spring-amqp 。但是国内访问github的速度,你知道的。
5.2.4:使用RabbitmqTemplate对象发送消息 生产者的所有属性都已经在application.properties配置文件中进行配置。项目启动时,就会在Spring容器中初始化一个RabbitmqTemplate对象,然后所有的发送消息操作都通过这个对象来进行。
5.2.5: 使用@RabbitListener注解声明消费者 消费者都是通过@RabbitListener注解来声明。注解中包含了声明消费者队列时所需要的重点参数。对照原生API,这些参数就不难理解了。但是当要消费Stream队列时,还是要重点注意他的三个必要的步骤: channel必须设置basicQos属性。 channel对象可以在@RabbitListener声明的消费者方法中直接引用,Spring框架会进行注入。 正确声明Stream队列。 通过往Spring容器中注入Queue对象的方式声明队列。在Queue对象中传入声明Stream队列所需要的参数。 消费时需要指定offset。 可以通过注入Channel对象,使用原生API传入offset属性。 使用SpringBoot框架集成RabbitMQ后,开发过程可以得到很大的简化,所以使用过程并不难,对照一下示例就能很快上手。但是,需要理解一下的是,SpringBoot集成后的RabbitMQ中的很多概念,虽然都能跟原生API对应上,但是这些模型中间都是做了转换的,比如Message,就不是原生RabbitMQ中的消息了。使用SpringBoot框架,尤其需要加深对RabbitMQ原生API的理解,这样才能以不变应万变,深入理解各种看起来简单,但是其实坑很多的各种对象声明方式。
5.2.6:关于Stream队列 在目前版本下,使用RabbitMQ的SpringBoot框架集成,可以正常声明Stream队列,往Stream队列发送消息,但是无法直接消费Stream队列了。 关于这个问题,还是需要从Stream队列的三个重点操作入手。SpringBoot框架集成RabbitMQ后,为了简化编程模型,就把channel,connection等这些关键对象给隐藏了,目前框架下,无法直接接入这些对象的注入过程,所以无法直接使用。 如果非要使用Stream队列,那么有两种方式,一种是使用原生API的方式,在SpringBoot框架下自行封装。另一种是使用RabbitMQ的Stream 插件。在服务端通过Strem插件打开TCP连接接口,并配合单独提供的Stream客户端使用。这种方式对应用端的影响太重了,并且并没有提供与SpringBoot框架的集成,还需要自行完善,因此选择使用的企业还比较少。
SpringBoot集成
RabbitMQ编程模型
这种策略很类似于kafka的分组消费策略。 我们回忆一下,在kafka中的分组策略,是不同的group,都会消费到同样的一份message副本,而在同一个group中,只会有一个消费者消费到一个message。这种分组消费策略,严格来说,在Rabbit中是不存在的。RabbitMQ是通过不同类型的exchange来实现不同的消费策略的。这虽然与kafka的这一套完全不同,但是在SpringCloudStream针对RabbitMQ的实现中,可以很容易的看到kafka这种分组策略的影子。 当有多个消费者实例消费同一个bingding时,Spring Cloud Stream同样是希望将这种分组策略,移植到RabbitMQ中来的。就是在不同的group中,会同样消费同一个Message,而在同一个group中,只会有一个消费者消息到一个Message。
要使用分组消费策略,需要在生产者和消费者两端都进行分组配置。 1、生产者端 核心配置#指定参与消息分区的消费端节点数量spring.cloud.stream.bindings.output.producer.partition-count=2#只有消费端分区ID为1的消费端能接收到消息spring.cloud.stream.bindings.output.producer.partition-key-expression=1 2、消费者端启动两个实例,组成一个消费者组 消费者1 核心配置#启动消费分区spring.cloud.stream.bindings.input.consumer.partitioned=true#参与分区的消费端节点个数spring.cloud.stream.bindings.input.consumer.instance-count=2#设置该实例的消费端分区IDspring.cloud.stream.bindings.input.consumer.instance-index=1 消费者2 核心配置#启动消费分区spring.cloud.stream.bindings.input.consumer.partitioned=true#参与分区的消费端节点个数spring.cloud.stream.bindings.input.consumer.instance-count=2#设置该实例的消费端分区IDspring.cloud.stream.bindings.input.consumer.instance-index=0 这样就完成了一个分组消费的配置。两个消费者实例会组成一个消费者组。而生产者发送的消息,只会被消费者1 消费到(生产者的partition-key-expression 和 消费者的 instance-index 匹配)。
分组消费模式
死信队列是RabbitMQ中非常重要的一个特性。简单理解,他是RabbitMQ对于未能正常消费的消息进行的一种补救机制。死信队列也是一个普通的队列,同样可以在队列上声明消费者,继续对消息进行消费处理。 对于死信队列,在RabbitMQ中主要涉及到几个参数。x-dead-letter-exchange: mirror.dlExchange 对应的死信交换机x-dead-letter-routing-key: mirror.messageExchange1.messageQueue1 死信交换机routing-keyx-message-ttl: 3000 消息过期时间durable: true 持久化,这个是必须的。 在这里,x-dead-letter-exchange指定一个交换机作为死信交换机,然后x-dead-letter-routing-key指定交换机的RoutingKey。而接下来,死信交换机就可以像普通交换机一样,通过RoutingKey将消息转发到对应的死信队列中。
何时会产生死信有以下三种情况,RabbitMQ会将一个正常消息转成死信消息被消费者确认拒绝。消费者把requeue参数设置为true(false),并且在消费后,向RabbitMQ返回拒绝。channel.basicReject或者channel.basicNack。消息达到预设的TTL时限还一直没有被消费。消息由于队列已经达到最长长度限制而被丢掉
死信队列的配置方式RabbitMQ中有两种方式可以声明死信队列,一种是针对某个单独队列指定对应的死信队列。另一种就是以策略的方式进行批量死信队列的配置。针对多个队列,可以使用策略方式,配置统一的死信队列。、rabbitmqctl set_policy DLX \".*\" '{\"dead-letter-exchange\":\"my-dlx\"}' --apply-to queues针对队列单独指定死信队列的方式主要是之前提到的三个属性。channel.exchangeDeclare(\"some.exchange.name\
死信队列如何消费 其实从前面的配置过程能够看到,所谓死信交换机或者死信队列,不过是在交换机或者队列之间建立一种死信对应关系,而死信队列可以像正常队列一样被消费。他与普通队列一样具有FIFO的特性。对死信队列的消费逻辑通常是对这些失效消息进行一些业务上的补偿。
死信队列
其中,1,2,4三个场景都是跨网络的,而跨网络就肯定会有丢消息的可能。 然后关于3这个环节,通常MQ存盘时都会先写入操作系统的缓存page cache中,然后再由操作系统异步的将消息写入硬盘。这个中间有个时间差,就可能会造成消息丢失。如果服务挂了,缓存中还没有来得及写入硬盘的消息就会丢失。这也是任何用户态的应用程序无法避免的。 对于任何MQ产品,都应该从这四个方面来考虑数据的安全性。那我们看看用RabbitMQ时要如何解决这个问题。
哪些环节会有丢消息的可能?
2》 RabbitMQ消息存盘不丢消息 这个在RabbitMQ中比较好处理,对于Classic经典队列,直接将队列声明成为持久化队列即可。而新增的Quorum队列和Stream队列,都是明显的持久化队列,能更好的保证服务端消息不会丢失。
3》 RabbitMQ 主从消息同步时不丢消息 这涉及到RabbitMQ的集群架构。首先他的普通集群模式,消息是分散存储的,不会主动进行消息同步了,是有可能丢失消息的。而镜像模式集群,数据会主动在集群各个节点当中同步,这时丢失消息的概率不会太高。 另外,启用Federation联邦机制,给包含重要消息的队列建立一个远端备份,也是一个不错的选择。
另外这个应答模式在SpringBoot集成案例中,也可以在配置文件中通过属性spring.rabbitmq.listener.simple.acknowledge-mode 进行指定。可以设定为 AUTO 自动应答; MANUAL 手动应答;NONE 不应答; 其中这个NONE不应答,就是不启动应答机制,RabbitMQ只管往消费者推送消息后,就不再重复推送消息了,相当于RocketMQ的sendoneway, 这样效率更高,但是显然会有丢消息的可能。 最后,任何用户态的应用程序都无法保证绝对的数据安全,所以,备份与恢复的方案也需要考虑到。
4》 RabbitMQ消费者不丢失消息 RabbitMQ在消费消息时可以指定是自动应答,还是手动应答。如果是自动应答模式,消费者会在完成业务处理后自动进行应答,而如果消费者的业务逻辑抛出异常,RabbitMQ会将消息进行重试,这样是不会丢失消息的,但是有可能会造成消息一直重复消费。 将RabbitMQ的应答模式设定为手动应答可以提高消息消费的可靠性。
RabbitMQ消息零丢失方案
RabbitMQ如何保证消息不丢失?
在SpringBoot框架集成RabbitMQ后,可以给每个消息指定一个全局唯一的MessageID,在消费者端针对MessageID做幂等性判断。关键代码://发送者指定ID字段Message message2 = MessageBuilder.withBody(message.getBytes()).setMessageId(UUID.randomUUID().toString()).build(); rabbitTemplate.send(message2);//消费者获取MessageID,自己做幂等性判断@RabbitListener(queues = \"fanout_email_queue\")public void process(Message message) throws Exception { // 获取消息Id String messageId = message.getMessageProperties().getMessageId(); ...}
在原生API当中,也是支持MessageId的。当然,在实际工作中,最好还是能够添加一个具有业务意义的数据作为唯一键会更好,这样能更好的防止重复消费问题对业务的影响。比如,针对订单消息,那就用订单ID来做唯一键。在RabbitMQ中,消息的头部就是一个很好的携带数据的地方。// ==== 发送消息时,携带sequenceNumber和orderNoAMQP.BasicProperties.Builder builder = new AMQP.BasicProperties.Builder();builder.deliveryMode(MessageProperties.PERSISTENT_TEXT_PLAIN.getDeliveryMode());builder.priority(MessageProperties.PERSISTENT_TEXT_PLAIN.getPriority());//携带消息IDbuilder.messageId(\"\
1、RabbitMQ的自动重试功能: 当消费者消费消息处理业务逻辑时,如果抛出异常,或者不向RabbitMQ返回响应,默认情况下,RabbitMQ会无限次数的重复进行消息消费。 处理幂等问题,首先要设定RabbitMQ的重试次数。在SpringBoot集成RabbitMQ时,可以在配置文件中指定spring.rabbitmq.listener.simple.retry开头的一系列属性,来制定重试策略。 然后,需要在业务上处理幂等问题。 处理幂等问题的关键是要给每个消息一个唯一的标识。
如何保证消息幂等?
某些场景下,需要保证消息的消费顺序,例如一个下单过程,需要先完成扣款,然后扣减库存,然后通知快递发货,这个顺序不能乱。如果每个步骤都通过消息进行异步通知的话,这一组消息就必须保证他们的消费顺序是一致的。 在RabbitMQ当中,针对消息顺序的设计其实是比较弱的。唯一比较好的策略就是 单队列+单消息推送。即一组有序消息,只发到一个队列中,利用队列的FIFO特性保证消息在队列内顺序不会乱。但是,显然,这是以极度消耗性能作为代价的,在实际适应过程中,应该尽量避免这种场景。 然后在消费者进行消费时,保证只有一个消费者,同时指定prefetch属性为1,即每次RabbitMQ都只往客户端推送一个消息。像这样:spring.rabbitmq.listener.simple.prefetch=1 而在多队列情况下,如何保证消息的顺序性,目前使用RabbitMQ的话,还没有比较好的解决方案。在使用时,应该尽量避免这种情况。
如何保证消息的顺序?
首先在消息生产者端: 对于生产者端,最明显的方式自然是降低消息生产的速度。但是,生产者端产生消息的速度通常是跟业务息息相关的,一般情况下不太好直接优化。但是可以选择尽量多采用批量消息的方式,降低IO频率。
然后在RabbitMQ服务端: 从前面的分享中也能看出,RabbitMQ本身其实也在着力于提高服务端的消息堆积能力。对于消息堆积严重的队列,可以预先添加懒加载机制,或者创建Sharding分片队列,这些措施都有助于优化服务端的消息堆积能力。另外,尝试使用Stream队列,也能很好的提高服务端的消息堆积能力。
接下来在消息消费者端: 要提升消费速度最直接的方式,就是增加消费者数量了。尤其当消费端的服务出现问题,已经有大量消息堆积时。这时,可以尽量多的申请机器,部署消费端应用,争取在最短的时间内消费掉积压的消息。但是这种方式需要注意对其他组件的性能压力。
对于单个消费者端,可以通过配置提升消费者端的吞吐量。例如# 单次推送消息数量spring.rabbitmq.listener.simple.prefetch=1# 消费者的消费线程数量spring.rabbitmq.listener.simple.concurrency=5 灵活配置这几个参数,能够在一定程度上调整每个消费者实例的吞吐量,减少消息堆积数量。 当确实遇到紧急状况,来不及调整消费者端时,可以紧急上线一个消费者组,专门用来将消息快速转录。保存到数据库或者Redis,然后再慢慢进行处理。
RabbitMQ一直以来都有一个缺点,就是对于消息堆积问题的处理不好。当RabbitMQ中有大量消息堆积时,整体性能会严重下降。而目前新推出的Quorum队列以及Stream队列,目的就在于解决这个核心问题。但是这两种队列的稳定性和周边生态都还不够完善,因此,在使用RabbitMQ时,还是要非常注意消息堆积的问题。尽量让消息的消费速度和生产速度保持一致。 而如果确实出现了消息堆积比较严重的场景,就需要从数据流转的各个环节综合考虑,设计适合的解决方案。
关于RabbitMQ的数据堆积问题
面试问题
RabbitMq
Kafka的使用场景日志收集:一个公司可以用Kafka收集各种服务的log,通过kafka以统一接口服务的方式开放给各种consumer,例如hadoop、Hbase、Solr等。消息系统:解耦和生产者和消费者、缓存消息等。用户活动跟踪:Kafka经常被用来记录web用户或者app用户的各种活动,如浏览网页、搜索、点击等活动,这些活动信息被各个服务器发布到kafka的topic中,然后订阅者通过订阅这些topic来做实时的监控分析,或者装载到hadoop、数据仓库中做离线分析和挖掘。运营指标:Kafka也经常用来记录运营监控数据。包括收集各种分布式应用的数据,生产各种操作的集中反馈,比如报警和报告。
首先,让我们来看一下基础的消息(Message)相关术语:名称 解释Broker 消息中间件处理节点,一个Kafka节点就是一个broker,一个或者多个Broker可以组成一个Kafka集群Topic Kafka根据topic对消息进行归类,发布到Kafka集群的每条消息都需要指定一个topicProducer 消息生产者,向Broker发送消息的客户端Consumer 消息消费者,从Broker读取消息的客户端ConsumerGroup 每个Consumer属于一个特定的Consumer Group,一条消息可以被多个不同的Consumer Group消费,但是一个Consumer Group中只能有一个Consumer能够消费该消息Partition 物理上的概念,一个topic可以分为多个partition,每个partition内部消息是有序的
因此,从一个较高的层面上来看,producer通过网络发送消息到Kafka集群,然后consumer来进行消费,如下图:服务端(brokers)和客户端(producer、consumer)之间通信通过TCP协议来完成。
Kafka基本概念kafka是一个分布式的,分区的消息(官方称之为commit log)服务。它提供一个消息系统应该具备的功能,但是确有着独特的设计。可以这样来说,Kafka借鉴了JMS规范的思想,但是确并没有完全遵循JMS规范。
创建主题 现在我们来创建一个名字为“test”的Topic,这个topic只有一个partition,并且备份因子也设置为1:bin/kafka-topics.sh --create --zookeeper 192.168.65.60:2181 --replication-factor 1 --partitions 1 --topic test现在我们可以通过以下命令来查看kafka中目前存在的topicbin/kafka-topics.sh --list --zookeeper 192.168.65.60:2181除了我们通过手工的方式创建Topic,当producer发布一个消息到某个指定的Topic,这个Topic如果不存在,就自动创建。
删除主题 bin/kafka-topics.sh --delete --topic test --zookeeper 192.168.65.60:2181
发送消息kafka自带了一个producer命令客户端,可以从本地文件中读取内容,或者我们也可以以命令行中直接输入内容,并将这些内容以消息的形式发送到kafka集群中。在默认情况下,每一个行会被当做成一个独立的消息。首先我们要运行发布消息的脚本,然后在命令中输入要发送的消息的内容:bin/kafka-console-producer.sh --broker-list 192.168.65.60:9092 --topic test >this is a msg>this is a another msg
消费多主题bin/kafka-console-consumer.sh --bootstrap-server 192.168.65.60:9092 --whitelist \"test|test-2\"
单播消费一条消息只能被某一个消费者消费的模式,类似queue模式,只需让所有消费者在同一个消费组里即可分别在两个客户端执行如下消费命令,然后往主题里发送消息,结果只有一个客户端能收到消息bin/kafka-console-consumer.sh --bootstrap-server 192.168.65.60:9092 --consumer-property group.id=testGroup --topic test
多播消费一条消息能被多个消费者消费的模式,类似publish-subscribe模式费,针对Kafka同一条消息只能被同一个消费组下的某一个消费者消费的特性,要实现多播只要保证这些消费者属于不同的消费组即可。我们再增加一个消费者,该消费者属于testGroup-2消费组,结果两个客户端都能收到消息bin/kafka-console-consumer.sh --bootstrap-server 192.168.65.60:9092 --consumer-property group.id=testGroup-2 --topic test
查看消费组名bin/kafka-consumer-groups.sh --bootstrap-server 192.168.65.60:9092 --list
查看消费组的消费偏移量bin/kafka-consumer-groups.sh --bootstrap-server 192.168.65.60:9092 --describe --group testGroupcurrent-offset:当前消费组的已消费偏移量log-end-offset:主题对应分区消息的结束偏移量(HW)lag:当前消费组未消费的消息数
消费消息对于consumer,kafka同样也携带了一个命令行客户端,会将获取到内容在命令中进行输出,默认是消费最新的消息:bin/kafka-console-consumer.sh --bootstrap-server 192.168.65.60:9092 --topic test 如果想要消费之前的消息可以通过--from-beginning参数指定,如下命令:bin/kafka-console-consumer.sh --bootstrap-server 192.168.65.60:9092 --from-beginning --topic test 如果你是通过不同的终端窗口来运行以上的命令,你将会看到在producer终端输入的内容,很快就会在consumer的终端窗口上显示出来。以上所有的命令都有一些附加的选项;当我们不携带任何参数运行命令的时候,将会显示出这个命令的详细用法。
可以理解Topic是一个类别的名称,同类消息发送到同一个Topic下面。对于每一个Topic,下面可以有多个分区(Partition)日志文件:Partition是一个有序的message序列,这些message按顺序添加到一个叫做commit log的文件中。每个partition中的消息都有一个唯一的编号,称之为offset,用来唯一标示某个分区中的message。 每个partition,都对应一个commit log文件。一个partition中的message的offset都是唯一的,但是不同的partition中的message的offset可能是相同的。kafka一般不会删除消息,不管这些消息有没有被消费。只会根据配置的日志保留时间(log.retention.hours)确认消息多久被删除,默认保留最近一周的日志消息。kafka的性能与保留的消息数据量大小没有关系,因此保存大量的数据消息日志信息不会有什么影响。每个consumer是基于自己在commit log中的消费进度(offset)来进行工作的。在kafka中,消费offset由consumer自己来维护;一般情况下我们按照顺序逐条消费commit log中的消息,当然我可以通过指定offset来重复消费某些消息,或者跳过某些消息。这意味kafka中的consumer对集群的影响是非常小的,添加一个或者减少一个consumer,对于集群或者其他consumer来说,都是没有影响的,因为每个consumer维护各自的消费offset。
创建多个分区的主题:bin/kafka-topics.sh --create --zookeeper 192.168.65.60:2181 --replication-factor 1 --partitions 2 --topic test1
以下是输出内容的解释,第一行是所有分区的概要信息,之后的每一行表示每一个partition的信息。leader节点负责给定partition的所有读写请求。replicas 表示某个partition在哪几个broker上存在备份。不管这个几点是不是”leader“,甚至这个节点挂了,也会列出。isr 是replicas的一个子集,它只列出当前还存活着的,并且已同步备份了该partition的节点。
可以进入kafka的数据文件存储目录查看test和test1主题的消息日志文件:
当然我们也可以通过如下命令增加topic的分区数量(目前kafka不支持减少分区):bin/kafka-topics.sh -alter --partitions 3 --zookeeper 192.168.65.60:2181 --topic test
查看下topic的情况 bin/kafka-topics.sh --describe --zookeeper 192.168.65.60:2181 --topic test1
可以这么来理解Topic,Partition和Broker一个topic,代表逻辑上的一个业务数据集,比如按数据库里不同表的数据操作消息区分放入不同topic,订单相关操作消息放入订单topic,用户相关操作消息放入用户topic,对于大型网站来说,后端数据都是海量的,订单消息很可能是非常巨量的,比如有几百个G甚至达到TB级别,如果把这么多数据都放在一台机器上可定会有容量限制问题,那么就可以在topic内部划分多个partition来分片存储数据,不同的partition可以位于不同的机器上,每台机器上都运行一个Kafka的进程Broker。为什么要对Topic下数据进行分区存储?1、commit log文件会受到所在机器的文件系统大小的限制,分区之后可以将不同的分区放在不同的机器上,相当于对数据做了分布式存储,理论上一个topic可以处理任意数量的数据。2、为了提高并行度。
主题Topic和消息日志Log
kafka基本使用
首先,我们需要建立好其他2个broker的配置文件:cp config/server.properties config/server-1.propertiescp config/server.properties config/server-2.properties
配置文件的需要修改的内容分别如下:config/server-1.properties:#broker.id属性在kafka集群中必须要是唯一broker.id=1#kafka部署的机器ip和提供服务的端口号listeners=PLAINTEXT://192.168.65.60:9093 log.dir=/usr/local/data/kafka-logs-1#kafka连接zookeeper的地址,要把多个kafka实例组成集群,对应连接的zookeeper必须相同zookeeper.connect=192.168.65.60:2181config/server-2.properties:broker.id=2listeners=PLAINTEXT://192.168.65.60:9094log.dir=/usr/local/data/kafka-logs-2zookeeper.connect=192.168.65.60:2181
目前我们已经有一个zookeeper实例和一个broker实例在运行了,现在我们只需要在启动2个broker实例即可:bin/kafka-server-start.sh -daemon config/server-1.propertiesbin/kafka-server-start.sh -daemon config/server-2.properties
对于kafka来说,一个单独的broker意味着kafka集群中只有一个节点。要想增加kafka集群中的节点数量,只需要多启动几个broker实例即可。为了有更好的理解,现在我们在一台机器上同时启动三个broker实例。首先,我们需要建立好其他2个broker的配置文件:
查看zookeeper确认集群节点是否都注册成功:
现在我们创建一个新的topic,副本数设置为3,分区数设置为2:bin/kafka-topics.sh --create --zookeeper 192.168.65.60:2181 --replication-factor 3 --partitions 2 --topic my-replicated-topic
以下是输出内容的解释,第一行是所有分区的概要信息,之后的每一行表示每一个partition的信息。leader节点负责给定partition的所有读写请求,同一个主题不同分区leader副本一般不一样(为了容灾)replicas 表示某个partition在哪几个broker上存在备份。不管这个几点是不是”leader“,甚至这个节点挂了,也会列出。isr 是replicas的一个子集,它只列出当前还存活着的,并且已同步备份了该partition的节点。
查看下topic的情况 bin/kafka-topics.sh --describe --zookeeper 192.168.65.60:2181 --topic my-replicated-topic
现在我们来测试我们容错性,因为broker1目前是my-replicated-topic的分区0的leader,所以我们要将其killps -ef | grep server.propertieskill 14776
我们可以看到,分区0的leader节点已经变成了broker 0。要注意的是,在Isr中,已经没有了1号节点。leader的选举也是从ISR(in-sync replica)中进行的。此时,我们依然可以 消费新消息:
现在再执行命令:bin/kafka-topics.sh --describe --zookeeper 192.168.65.60:2181 --topic my-replicated-topic
查看主题分区对应的leader信息:kafka将很多集群关键信息记录在zookeeper里,保证自己的无状态,从而在水平扩容时非常方便。
集群环境
Producers生产者将消息发送到topic中去,同时负责选择将message发送到topic的哪一个partition中。通过round-robin做简单的负载均衡。也可以根据消息中的某一个关键字来进行区分。通常第二种方式使用的更多。
上图说明:由2个broker组成的kafka集群,某个主题总共有4个partition(P0-P3),分别位于不同的broker上。这个集群由2个Consumer Group消费, A有2个consumer instances ,B有4个。通常一个topic会有几个consumer group,每个consumer group都是一个逻辑上的订阅者( logical subscriber )。每个consumer group由多个consumer instance组成,从而达到可扩展和容灾的功能。
Consumers传统的消息传递模式有2种:队列( queue) 和(publish-subscribe)queue模式:多个consumer从服务器中读取数据,消息只会到达一个consumer。publish-subscribe模式:消息会被广播给所有的consumer。Kafka基于这2种模式提供了一种consumer的抽象概念:consumer group。queue模式:所有的consumer都位于同一个consumer group 下。publish-subscribe模式:所有的consumer都有着自己唯一的consumer group。
消费顺序一个partition同一个时刻在一个consumer group中只能有一个consumer instance在消费,从而保证消费顺序。consumer group中的consumer instance的数量不能比一个Topic中的partition的数量多,否则,多出来的consumer消费不到消息。Kafka只在partition的范围内保证消息消费的局部顺序性,不能在同一个topic中的多个partition中保证总的消费顺序性。如果有在总体上保证消费顺序的需求,那么我们可以通过将topic的partition数量设置为1,将consumer group中的consumer instance数量也设置为1,但是这样会影响性能,所以kafka的顺序消费很少用。
log的partitions分布在kafka集群中不同的broker上,每个broker可以请求备份其他broker上partition上的数据。kafka集群支持配置一个partition备份的数量。针对每个partition,都有一个broker起到“leader”的作用,0个或多个其他的broker作为“follwers”的作用。leader处理所有的针对这个partition的读写请求,而followers被动复制leader的结果,不提供读写(主要是为了保证多副本数据与消费的一致性)。如果这个leader失效了,其中的一个follower将会自动的变成新的leader。
集群消费
引入maven依赖<dependency> <groupId>org.apache.kafka</groupId> <artifactId>kafka-clients</artifactId> <version>2.4.1</version></dependency>消息发送端代码
消息发送端代码package com.tuling.kafka.kafkaDemo;import com.alibaba.fastjson.JSON;import org.apache.kafka.clients.producer.*;import org.apache.kafka.common.serialization.StringSerializer;import java.util.Properties;import java.util.concurrent.CountDownLatch;import java.util.concurrent.ExecutionException;import java.util.concurrent.TimeUnit;public class MsgProducer { private final static String TOPIC_NAME = \"my-replicated-topic\
消息接收端代码package com.tuling.kafka.kafkaDemo;import org.apache.kafka.clients.consumer.ConsumerConfig;import org.apache.kafka.clients.consumer.ConsumerRecord;import org.apache.kafka.clients.consumer.ConsumerRecords;import org.apache.kafka.clients.consumer.KafkaConsumer;import org.apache.kafka.common.serialization.StringDeserializer;import java.time.Duration;import java.util.Arrays;import java.util.Properties;public class MsgConsumer { private final static String TOPIC_NAME = \"my-replicated-topic\"; private final static String CONSUMER_GROUP_NAME = \"testGroup\
Java客户端访问Kafka
引入spring boot kafka依赖,详见项目实例:spring-boot-kafka<dependency> <groupId>org.springframework.kafka</groupId> <artifactId>spring-kafka</artifactId></dependency>application.yml配置如下:
发送者代码:package com.kafka;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.kafka.core.KafkaTemplate;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;@RestControllerpublic class KafkaController { private final static String TOPIC_NAME = \"my-replicated-topic\
package com.kafka;import org.apache.kafka.clients.consumer.ConsumerRecord;import org.springframework.kafka.annotation.KafkaListener;import org.springframework.kafka.support.Acknowledgment;import org.springframework.stereotype.Component;@Componentpublic class MyConsumer { /** * @KafkaListener(groupId = \"testGroup\
Spring Boot整合Kafka
kafka集群实战
Controller选举机制在kafka集群启动的时候,会自动选举一台broker作为controller来管理整个集群,选举的过程是集群中每个broker都会尝试在zookeeper上创建一个 /controller 临时节点,zookeeper会保证有且仅有一个broker能创建成功,这个broker就会成为集群的总控器controller。 当这个controller角色的broker宕机了,此时zookeeper临时节点会消失,集群里其他broker会一直监听这个临时节点,发现临时节点消失了,就竞争再次创建临时节点,就是我们上面说的选举机制,zookeeper又会保证有一个broker成为新的controller。具备控制器身份的broker需要比其他普通的broker多一份职责,具体细节如下: 监听broker相关的变化。为Zookeeper中的/brokers/ids/节点添加BrokerChangeListener,用来处理broker增减的变化。 监听topic相关的变化。为Zookeeper中的/brokers/topics节点添加TopicChangeListener,用来处理topic增减的变化;为Zookeeper中的/admin/delete_topics节点添加TopicDeletionListener,用来处理删除topic的动作。 从Zookeeper中读取获取当前所有与topic、partition以及broker有关的信息并进行相应的管理。对于所有topic所对应的Zookeeper中的/brokers/topics/[topic]节点添加PartitionModificationsListener,用来监听topic中的分区分配变化。 更新集群的元数据信息,同步到其他普通的broker节点中。
Partition副本选举Leader机制controller感知到分区leader所在的broker挂了(controller监听了很多zk节点可以感知到broker存活),controller会从ISR列表(参数unclean.leader.election.enable=false的前提下)里挑第一个broker作为leader(第一个broker最先放进ISR列表,可能是同步数据最多的副本),如果参数unclean.leader.election.enable为true,代表在ISR列表里所有副本都挂了的时候可以在ISR列表以外的副本中选leader,这种设置,可以提高可用性,但是选出的新leader有可能数据少很多。副本进入ISR列表有两个条件: 副本节点不能产生网络分区,必须能与zookeeper保持会话以及跟leader副本网络连通 副本能复制leader上的所有写操作,并且不能落后太多。(与leader副本同步滞后的副本,是由 replica.lag.time.max.ms 配置决定的,超过这个时间都没有跟leader同步过的一次的副本会被移出ISR列表)
Kafka核心总控制器Controller在Kafka集群中会有一个或者多个broker,其中有一个broker会被选举为控制器(Kafka Controller),它负责管理整个集群中所有分区和副本的状态。当某个分区的leader副本出现故障时,由控制器负责为该分区选举新的leader副本。当检测到某个分区的ISR集合发生变化时,由控制器负责通知所有broker更新其元数据信息。当使用kafka-topics.sh脚本为某个topic增加分区数量时,同样还是由控制器负责让新分区被其他节点感知到。
消费者消费消息的offset记录机制每个consumer会定期将自己消费分区的offset提交给kafka内部topic:__consumer_offsets,提交过去的时候,key是consumerGroupId+topic+分区号,value就是当前offset的值,kafka会定期清理topic里的消息,最后就保留最新的那条数据 因为__consumer_offsets可能会接收高并发的请求,kafka默认给其分配50个分区(可以通过offsets.topic.num.partitions设置),这样可以通过加机器的方式抗大并发。通过如下公式可以选出consumer消费的offset要提交到__consumer_offsets的哪个分区公式:hash(consumerGroupId) % __consumer_offsets主题的分区数
range策略就是按照分区序号排序,假设 n=分区数/消费者数量 = 3, m=分区数%消费者数量 = 1,那么前 m 个消费者每个分配 n+1 个分区,后面的(消费者数量-m )个消费者每个分配 n 个分区。比如分区0~3给一个consumer,分区4~6给一个consumer,分区7~9给一个consumer。
round-robin策略就是轮询分配,比如分区0、3、6、9给一个consumer,分区1、4、7给一个consumer,分区2、5、8给一个consumer
sticky策略初始时分配策略与round-robin类似,但是在rebalance的时候,需要保证如下两个原则。1)分区的分配要尽可能均匀 。2)分区的分配尽可能与上次分配的保持相同。当两者发生冲突时,第一个目标优先于第二个目标 。这样可以最大程度维持原来的分区分配的策略。比如对于第一种range情况的分配,如果第三个consumer挂了,那么重新用sticky策略分配的结果如下:consumer1除了原有的0~3,会再分配一个7consumer2除了原有的4~6,会再分配8和9
消费者Rebalance分区分配策略:主要有三种rebalance的策略:range、round-robin、sticky。Kafka 提供了消费者客户端参数partition.assignment.strategy 来设置消费者与订阅主题之间的分区分配策略。默认情况为range分配策略。假设一个主题有10个分区(0-9),现在有三个consumer消费:
第一阶段:选择组协调器组协调器GroupCoordinator:每个consumer group都会选择一个broker作为自己的组协调器coordinator,负责监控这个消费组里的所有消费者的心跳,以及判断是否宕机,然后开启消费者rebalance。consumer group中的每个consumer启动时会向kafka集群中的某个节点发送 FindCoordinatorRequest 请求来查找对应的组协调器GroupCoordinator,并跟其建立网络连接。组协调器选择方式:consumer消费的offset要提交到__consumer_offsets的哪个分区,这个分区leader对应的broker就是这个consumer group的coordinator
第二阶段:加入消费组JOIN GROUP在成功找到消费组所对应的 GroupCoordinator 之后就进入加入消费组的阶段,在此阶段的消费者会向 GroupCoordinator 发送 JoinGroupRequest 请求,并处理响应。然后GroupCoordinator 从一个consumer group中选择第一个加入group的consumer作为leader(消费组协调器),把consumer group情况发送给这个leader,接着这个leader会负责制定分区方案。
第三阶段( SYNC GROUP)consumer leader通过给GroupCoordinator发送SyncGroupRequest,接着GroupCoordinator就把分区方案下发给各个consumer,他们会根据指定分区的leader broker进行网络连接以及消息消费。
Rebalance过程如下当有消费者加入消费组时,消费者、消费组及组协调器之间会经历以下几个阶段。
消费者Rebalance机制rebalance就是说如果消费组里的消费者数量有变化或消费的分区数有变化,kafka会重新分配消费者消费分区的关系。比如consumer group中某个消费者挂了,此时会自动把分配给他的分区交给其他的消费者,如果他又重启了,那么又会把一些分区重新交还给他。注意:rebalance只针对subscribe这种不指定分区消费的情况,如果通过assign这种消费方式指定了分区,kafka不会进行rebanlance。如下情况可能会触发消费者rebalance 消费组里的consumer增加或减少了 动态给topic增加了分区 消费组订阅了更多的topicrebalance过程中,消费者无法从kafka消费消息,这对kafka的TPS会有影响,如果kafka集群内节点较多,比如数百个,那重平衡可能会耗时极多,所以应尽量避免在系统高峰期的重平衡发生。
1、写入方式producer 采用 push 模式将消息发布到 broker,每条消息都被 append 到 patition 中,属于顺序写磁盘(顺序写磁盘效率比随机写内存要高,保障 kafka 吞吐率)。
2、消息路由producer 发送消息到 broker 时,会根据分区算法选择将其存储到哪一个 partition。其路由机制为:1. 指定了 patition,则直接使用;2. 未指定 patition 但指定 key,通过对 key 的 value 进行hash 选出一个 patition3. patition 和 key 都未指定,使用轮询选出一个 patition。
3、写入流程1. producer 先从 zookeeper 的 \"/brokers/.../state\" 节点找到该 partition 的 leader2. producer 将消息发送给该 leader3. leader 将消息写入本地 log4. followers 从 leader pull 消息,写入本地 log 后 向leader 发送 ACK5. leader 收到所有 ISR 中的 replica 的 ACK 后,增加 HW(high watermark,最后 commit 的 offset) 并向 producer 发送 ACK
producer发布消息机制剖析
下图详细的说明了当producer生产消息至broker后,ISR以及HW和LEO的流转过程:由此可见,Kafka的复制机制既不是完全的同步复制,也不是单纯的异步复制。事实上,同步复制要求所有能工作的follower都复制完,这条消息才会被commit,这种复制方式极大的影响了吞吐率。而异步复制方式下,follower异步的从leader复制数据,数据只要被leader写入log就被认为已经commit,这种情况下如果follower都还没有复制完,落后于leader时,突然leader宕机,则会丢失数据。而Kafka的这种使用ISR的方式则很好的均衡了确保数据不丢失以及吞吐率。再回顾下消息发送端对发出消息持久化机制参数acks的设置,我们结合HW和LEO来看下acks=1的情况
结合HW和LEO看下 acks=1的情况
HW与LEO详解
Kafka 一个分区的消息数据对应存储在一个文件夹下,以topic名称+分区号命名,消息在分区内是分段(segment)存储,每个段的消息都存储在不一样的log文件里,这种特性方便old segment file快速被删除,kafka规定了一个段位的 log 文件最大为 1G,做这个限制目的是为了方便把 log 文件加载到内存去操作:
# 部分消息的offset索引文件,kafka每次往分区发4K(可配置)消息就会记录一条当前消息的offset到index文件,# 如果要定位消息的offset会先在这个文件里快速定位,再去log文件里找具体消息00000000000000000000.index# 消息存储文件,主要存offset和消息体00000000000000000000.log# 消息的发送时间索引文件,kafka每次往分区发4K(可配置)消息就会记录一条当前消息的发送时间戳与对应的offset到timeindex文件,# 如果需要按照时间来定位消息的offset,会先在这个文件里查找00000000000000000000.timeindex00000000000005367851.index00000000000005367851.log00000000000005367851.timeindex00000000000009936472.index00000000000009936472.log00000000000009936472.timeindex
这个 9936472 之类的数字,就是代表了这个日志段文件里包含的起始 Offset,也就说明这个分区里至少都写入了接近 1000 万条数据了。Kafka Broker 有一个参数,log.segment.bytes,限定了每个日志段文件的大小,最大就是 1GB。一个日志段文件满了,就自动开一个新的日志段文件来写入,避免单个文件过大,影响文件的读写性能,这个过程叫做 log rolling,正在被写入的那个日志段文件,叫做 active log segment。
日志分段存储
zookeeper节点数据图
Kafka设计原理详解
JVM参数设置kafka是scala语言开发,运行在JVM上,需要对JVM参数合理设置,参看JVM调优专题修改bin/kafka-start-server.sh中的jvm设置,假设机器是32G内存,可以如下设置:export KAFKA_HEAP_OPTS=\"-Xmx16G -Xms16G -Xmn10G -XX:MetaspaceSize=256M -XX:+UseG1GC -XX:MaxGCPauseMillis=50 -XX:G1HeapRegionSize=16M\"这种大内存的情况一般都要用G1垃圾收集器,因为年轻代内存比较大,用G1可以设置GC最大停顿时间,不至于一次minor gc就花费太长时间,当然,因为像kafka,rocketmq,es这些中间件,写数据到磁盘会用到操作系统的page cache,所以JVM内存不宜分配过大,需要给操作系统的缓存留出几个G。
线上环境规划
1、消息丢失情况:消息发送端:(1)acks=0: 表示producer不需要等待任何broker确认收到消息的回复,就可以继续发送下一条消息。性能最高,但是最容易丢消息。大数据统计报表场景,对性能要求很高,对数据丢失不敏感的情况可以用这种。(2)acks=1: 至少要等待leader已经成功将数据写入本地log,但是不需要等待所有follower是否成功写入。就可以继续发送下一条消息。这种情况下,如果follower没有成功备份数据,而此时leader又挂掉,则消息会丢失。(3)acks=-1或all: 这意味着leader需要等待所有备份(min.insync.replicas配置的备份个数)都成功写入日志,这种策略会保证只要有一个备份存活就不会丢失数据。这是最强的数据保证。一般除非是金融级别,或跟钱打交道的场景才会使用这种配置。当然如果min.insync.replicas配置的是1则也可能丢消息,跟acks=1情况类似。消息消费端:如果消费这边配置的是自动提交,万一消费到数据还没处理完,就自动提交offset了,但是此时你consumer直接宕机了,未处理完的数据丢失了,下次也消费不到了。
2、消息重复消费消息发送端:发送消息如果配置了重试机制,比如网络抖动时间过长导致发送端发送超时,实际broker可能已经接收到消息,但发送方会重新发送消息消息消费端:如果消费这边配置的是自动提交,刚拉取了一批数据处理了一部分,但还没来得及提交,服务挂了,下次重启又会拉取相同的一批数据重复处理一般消费端都是要做消费幂等处理的。
3、消息乱序如果发送端配置了重试机制,kafka不会等之前那条消息完全发送成功才去发送下一条消息,这样可能会出现,发送了1,2,3条消息,第一条超时了,后面两条发送成功,再重试发送第1条消息,这时消息在broker端的顺序就是2,3,1了所以,是否一定要配置重试要根据业务情况而定。也可以用同步发送的模式去发消息,当然acks不能设置为0,这样也能保证消息发送的有序。kafka保证全链路消息顺序消费,需要从发送端开始,将所有有序消息发送到同一个分区,然后用一个消费者去消费,但是这种性能比较低,可以在消费者端接收到消息后将需要保证顺序消费的几条消费发到内存队列(可以搞多个),一个内存队列开启一个线程顺序处理消息。
4、消息积压1)线上有时因为发送方发送消息速度过快,或者消费方处理消息过慢,可能会导致broker积压大量未消费消息。此种情况如果积压了上百万未消费消息需要紧急处理,可以修改消费端程序,让其将收到的消息快速转发到其他topic(可以设置很多分区),然后再启动多个消费者同时消费新主题的不同分区。2)由于消息数据格式变动或消费者程序有bug,导致消费者一直消费不成功,也可能导致broker积压大量未消费消息。此种情况可以将这些消费不成功的消息转发到其它队列里去(类似死信队列),后面再慢慢分析死信队列里的消息处理问题。
实现思路:发送延时消息时先把消息按照不同的延迟时间段发送到指定的队列中(topic_1s,topic_5s,topic_10s,...topic_2h,这个一般不能支持任意时间段的延时),然后通过定时器进行轮训消费这些topic,查看消息是否到期,如果到期就把这个消息发送到具体业务处理的topic中,队列中消息越靠前的到期时间越早,具体来说就是定时器在一次消费过程中,对消息的发送时间做判断,看下是否延迟到对应时间了,如果到了就转发,如果还没到这一次定时任务就可以提前结束了。
5、延时队列延时队列存储的对象是延时消息。所谓的“延时消息”是指消息被发送以后,并不想让消费者立刻获取,而是等待特定的时间后,消费者才能获取这个消息进行消费,延时队列的使用场景有很多, 比如 :1)在订单系统中, 一个用户下单之后通常有 30 分钟的时间进行支付,如果 30 分钟之内没有支付成功,那么这个订单将进行异常处理,这时就可以使用延时队列来处理这些订单了。2)订单完成1小时后通知用户进行评价。
6、消息回溯如果某段时间对已消费消息计算的结果觉得有问题,可能是由于程序bug导致的计算错误,当程序bug修复后,这时可能需要对之前已消费的消息重新消费,可以指定从多久之前的消息回溯消费,这种可以用consumer的offsetsForTimes、seek等方法指定从某个offset偏移的消息开始消费,参见上节课的内容。
网络上很多资料都说分区数越多吞吐量越高 , 但从压测结果来看,分区数到达某个值吞吐量反而开始下降,实际上很多事情都会有一个临界值,当超过这个临界值之后,很多原本符合既定逻辑的走向又会变得不同。一般情况分区数跟集群机器数量相当就差不多了。当然吞吐量的数值和走势还会和磁盘、文件系统、 I/O调度策略等因素相关。注意:如果分区数设置过大,比如设置10000,可能会设置不成功,后台会报错\"java.io.IOException : Too many open files\"。异常中最关键的信息是“ Too many open flies”,这是一种常见的 Linux 系统错误,通常意味着文件描述符不足,它一般发生在创建线程、创建 Socket、打开文件这些场景下 。 在 Linux系统的默认设置下,这个文件描述符的个数不是很多 ,通过 ulimit -n 命令可以查看:一般默认是1024,可以将该值增大,比如:ulimit -n 65535
7、分区数越多吞吐量越高吗可以用kafka压测工具自己测试分区数不同,各种情况下的吞吐量# 往test里发送一百万消息,每条设置1KB# throughput 用来进行限流控制,当设定的值小于 0 时不限流,当设定的值大于 0 时,当发送的吞吐量大于该值时就会被阻塞一段时间bin/kafka-producer-perf-test.sh --topic test --num-records 1000000 --record-size 1024 --throughput -1 --producer-props bootstrap.servers=192.168.65.60:9092 acks=1
kafka的事务处理可以参考官方文档: Properties props = new Properties(); props.put(\"bootstrap.servers\
9、kafka的事务Kafka的事务不同于Rocketmq,Rocketmq是保障本地事务(比如数据库)与mq消息发送的事务一致性,Kafka的事务主要是保障一次发送多条消息的事务一致性(要么同时成功要么同时失败),一般在kafka的流式计算场景用得多一点,比如,kafka需要对一个topic里的消息做不同的流式计算处理,处理完分别发到不同的topic里,这些topic分别被不同的下游系统消费(比如hbase,redis,es等),这种我们肯定希望系统发送到多个topic的数据保持事务一致性。Kafka要实现类似Rocketmq的分布式事务需要额外开发功能。
数据传输零拷贝原理:
10、kafka高性能的原因磁盘顺序读写:kafka消息不能修改以及不会从文件中间删除保证了磁盘顺序读,kafka的消息写入文件都是追加在文件末尾,不会写入文件中的某个位置(随机写)保证了磁盘顺序写。数据传输的零拷贝读写数据的批量batch处理以及压缩传输
线上问题及优化
生产问题
Kafa
RocketMQ是阿里巴巴开源的一个消息中间件,在阿里内部历经了双十一等很多高并发场景的考验,能够处理亿万级别的消息。2016年开源后捐赠给Apache,现在是Apache的一个顶级项目。 目前RocketMQ在阿里云上有一个购买即可用的商业版本,商业版本集成了阿里内部一些更深层次的功能及运维定制。我们这里学习的是Apache的开源版本。开源版本相对于阿里云上的商业版本,功能上略有缺失,但是大体上功能是一样的。
1.1、RocketMQ的发展历程 早期阿里使用ActiveMQ,但是,当消息开始逐渐增多后,ActiveMQ的IO性能很快达到了瓶颈。于是,阿里开始关注Kafka。但是Kafka是针对日志收集场景设计的,他的并发性能并不是很理想。尤其当他的Topic过多时,由于Partition文件也会过多,会严重影响IO性能。于是阿里才决定自研中间件,最早叫做MetaQ,后来改名成为RocketMQ。最早他所希望解决的最大问题就是多Topic下的IO性能压力。但是产品在阿里内部的不断改进,RocketMQ开始体现出一些不一样的优势。
1.2、RocketMQ产品特点比较 RocketMQ的消息吞吐量虽然依然不如Kafka,但是却比RabbitMQ高很多。在阿里内部,RocketMQ集群每天处理的请求数超过5万亿次,支持的核心应用超过3000个。 RocketMQ天生就为金融互联网而生,因此他的消息可靠性相比Kafka也有了很大的提升,而消息吞吐量相比RabbitMQ也有很大的提升。另外,RocketMQ的高级功能也越来越全面,广播消费、延迟队列、死信队列等等高级功能一应俱全,甚至某些业务功能比如事务消息,已经呈现出领先潮流的趋势。 RocketMQ的源码是用Java开发的,这也使得很多互联网公司可以根据自己的业务需求做深度定制。而RocketMQ经过阿里双十一多次考验,源码的稳定性是值得信赖的,这使得功能定制有一个非常高的起点。 传统意义上,RocketMQ有一个比较大的局限,就是他的客户端只支持Java语言。但RocketMQ作为一个开源软件,自身产品不断成熟的同时,周边的技术生态也需要不断演进。RocketMQ成为Apache顶级项目后,又继续通过社区开发出了很多与主流技术生态融合的周边产品。例如在RocketMQ的社区,也正在开发GO,Python,Nodejs等语言的客户端。下图列出了RocketMQ社区目前的一些项目
一、RocketMQ介绍
2.31 RocketMQ工作原理 运行之前,我们需要对RocketMQ的组件结构有个大致的了解。RocketMQ由以下这几个组件组成NameServer : 提供轻量级的Broker路由服务。Broker:实际处理消息存储、转发等服务的核心组件。Producer:消息生产者集群。通常是业务系统中的一个功能模块。Consumer:消息消费者集群。通常也是业务系统中的一个功能模块。所以我们要启动RocketMQ服务,需要先启动NameServer。
但是要注意,RocketMQ默认预设的JVM内存是4G,这是RocketMQ给我们的最佳配置。但是通常我们用虚拟机的话都是不够4G内存的,所以需要调整下JVM内存大小。修改的方式是直接修改runserver.sh。 用vi runserver.sh编辑这个脚本,在脚本中找到这一行调整内存大小为512MJAVA_OPT=\"${JAVA_OPT} -server -Xms512m -Xmx512m -Xmn256m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=320m\" 然后我们用静默启动的方式启动NameServer服务:nohup bin/mqnamesrv & 启动完成后,在nohup.out里看到这一条关键日志就是启动成功了。并且使用jps指令可以看到有一个NamesrvStartup进程。Java HotSpot(TM) 64-Bit Server VM warning: Using the DefNew young collector with the CMScollector is deprecated and will likely be removed in a future releaseJava HotSpot(TM) 64-Bit Server VM warning: UseCMSCompactAtFullCollection is deprecated andwill likely be removed in a future release.The Name Server boot success. serializeType=JSON
2.3.2 NameServer服务搭建 启动NameServer非常简单, 在$ROCKETMQ_HOME/bin目录下有个mqadminsrv。直接执行这个脚本就可以启动RocketMQ的NameServer服务。
vi runbroker.sh,找到这一行,进行内存调整JAVA_OPT=\"${JAVA_OPT} -server -Xms512m -Xmx512m\
2.3.3 Broker服务搭建 启动Broker的脚本是runbroker.sh。Broker的默认预设内存是8G,启动前,如果内存不够,同样需要调整下JVM内存。
我们在worker2上进入RocketMQ的安装目录:首先需要配置一个环境变量NAMESRV_ADDR指向我们启动的NameServer服务。export NAMESRV_ADDR='localhost:9876' 然后启动消息生产者发送消息:默认会发1000条消息bin/tools.sh org.apache.rocketmq.example.quickstart.Producer
2.3.3 命令行启动客户端 在RocketMQ的安装包中,提供了一个tools.sh工具可以用来在命令行快速验证RocketMQ服务。
2.3.4 关闭RocketMQ服务要关闭RocketMQ服务可以通过mqshutdown脚本直接关闭# 1.关闭NameServersh bin/mqshutdown namesrv# 2.关闭Brokersh bin/mqshutdown broker
2.3、 快速运行RocketMQ
二、RocketMQ快速实战
刚才的演示中,我们已经体验到了RocketMQ是如何工作的。这样,我们回头看RocketMQ的集群架构,就能够有更全面的理解了。
一个完整的RocketMQ集群中,有如下几个角色Producer:消息的发送者;举例:发信者Consumer:消息接收者;举例:收信者Broker:暂存和传输消息;举例:邮局NameServer:管理Broker;举例:各个邮局的管理机构Topic:区分消息的种类;一个发送者可以发送消息给一个或者多个Topic;一个消息的接收者可以订阅一个或者多个Topic消息 我们之前的测试案例中,Topic是什么?topic='TopicTest' 现在你能看懂我们之前在broker.conf中添加的autoCreateTopicEnable=true这个属性的用处了吗?Message Queue:相当于是Topic的分区;用于并行发送和接收消息 在我们之前的测试案例中,一个queueId就代表了一个MessageQueue。有哪些queueId? 0,1,2,3四个MessageQueue,你都找到了吗?
3.1、RocketMQ集群架构解析
3.2.1 实验环境 准备三台虚拟机,硬盘空间建议大于4G。配置机器名。#vi /etc/hosts192.168.232.128 worker1192.168.232.129 worker2192.168.232.130 worker3
3.2.2 创建用户--可选useradd operpasswd oper (密码输入 123qweasd)
3.2.3 系统配置免密登录切换oper用户,在worker1上 生成keyssh-kengen然后分发给其他机器ssh-copy-id worker1ssh-copy-id worker2ssh-copy-id worker3这样就可以在worker1上直接ssh 或者scp到另外的机器,不需要输密码了。关闭防火墙systemctl stop firewalld.servicefirewall-cmd --state
3.2.4 安装JDK1.8 - 略3.2.5 安装RocketMQ上传配套的运行包rocketmq-all-4.9.1-bin-release.zip,直接解压到/app/rocketmq目录。然后配置环境变量export ROCKETMQ_HOME=/app/rocketmq/rocketmq-all-4.9.1-bin-release
该节点对应的从节点在worker3上。修改2m-2s-async/broker-a-s.properties
1、配置第一组broker-a在worker2上先配置borker-a的master节点。先配置2m-2s-async/broker-a.properties
然后他对应的slave在worker2上,修改work2上的 conf/2m-2s-async/broker-b-s.properties
2、配置第二组Broker-b这一组broker的主节点在worker3上,所以需要配置worker3上的config/2m-2s-async/broker-b.properties
1、先启动nameServer修改三个节点上的bin/runserver.sh,调整里面的jvm内存配置。找到下面这一行调整下内存JAVA_OPT=\"${JAVA_OPT} -server -Xms512m -Xmx512m -Xmn256m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=320m\"直接在三个节点上启动nameServer。nohup bin/mqnamesrv &启动完成后,在nohup.out里看到这一条关键日志就是启动成功了。Java HotSpot(TM) 64-Bit Server VM warning: Using the DefNew young collector with the CMS collector is deprecated and will likely be removed in a future releaseJava HotSpot(TM) 64-Bit Server VM warning: UseCMSCompactAtFullCollection is deprecated and will likely be removed in a future release.The Name Server boot success. serializeType=JSON使用jps指令可以看到一个NamesrvStartup进程。这里也看到,RocketMQ在runserver.sh中是使用的CMS垃圾回收期,而在runbroker.sh中使用的是G1垃圾回收期。
3、启动状态检查使用jps指令,能看到一个NameSrvStartup进程和两个BrokerStartup进程。nohup.out中也有启动成功的日志。对应的日志文件:# 查看nameServer日志tail -500f ~/logs/rocketmqlogs/namesrv.log# 查看broker日志tail -500f ~/logs/rocketmqlogs/broker.log
4、测试mqadmin管理工具 RocketMQ源码中并没有提供管理控制台,只提供了一个mqadmin指令来管理RocketMQ。指令的位置在bin目录下。直接使用该指令就会列出所有支持的命令。使用方式都是 mqadmin {command} {args}。 如果有某个指令不会使用,可以使用 mqadmin help {command} 指令查看帮助。
5、命令行快速验证 RocketMQ提供了一个tools.sh工具可以用来在命令行快速验证RocketMQ服务。例如,在worker2机器上进入RocketMQ的安装目录:发送消息:默认会发1000条消息bin/tools.sh org.apache.rocketmq.example.quickstart.Producer接收消息:bin/tools.sh org.apache.rocketmq.example.quickstart.Consumer注意,这是官方提供的Demo,但是官方的源码中,这两个类都是没有指定nameServer的,所以运行会有点问题。要指定NameServer地址,可以配置一个环境变量NAMESRV_ADDR,这样默认会读取这个NameServer地址。可以配到.bash_profile里或者直接临时指定。export NAMESRV_ADDR='worker1:9876;worker2:9876;worker3:9876'然后就可以正常执行了。这个tooles.sh实际上是封装了一个简单的运行RocketMQ的环境,上面指令中指定的Java类,都在lib/rocketmq-example-4.7.1.jar包中。未来如果自己有一些客户端示例,也可以打成jar包放到这个lib目录下,通过tools.sh运行。
3.2.7 启动RocketMQ启动就比较简单了,直接调用bin目录下的脚本就行。只是启动之前要注意看下他们的JVM内存配置,默认的配置都比较高。
下载下来后,解压并进入对应的目录,使用maven进行编译mvn clean package -Dmaven.test.skip=true编译完成后,获取target下的jar包,就可以直接执行。但是这个时候要注意,在这个项目的application.yml中需要指定nameserver的地址。默认这个属性是指向本地。如果配置为空,会读取环境变量NAMESRV_ADDR。那我们可以在jar包的当前目录下增加一个application.yml文件,覆盖jar包中默认的一个属性:rocketmq: config: namesrvAddrs: - worker1:9876 - worker2:9876 - worker3:9876然后执行:java -jar rocketmq-dashboard-1.0.1-SNAPSHOT.jar启动完成后,可以访问 http://192.168.232.128:8080看到管理页面
3.2.8 搭建管理控制台RocketMQ源代码中并没有提供控制台,但是有一个Rocket的社区扩展项目中提供了一个控制台,地址: https://github.com/apache/rocketmq-dashboard
通过这种方式,我们搭建了一个主从结构的RocketMQ集群,但是我们要注意,这种主从结构是只做数据备份,没有容灾功能的。也就是说当一个master节点挂了后,slave节点是无法切换成master节点继续提供服务的。注意这个集群至少要是3台,允许少于一半的节点发生故障。如果slave挂了,对集群的影响不会很大,因为slave只是做数据备份的。但是影响也是会有的,例如,当消费者要拉取的数据量比较大时,RocketMQ有一定的机制会优先保证Master节点的性能,只让Master节点返回一小部分数据,而让其他部分的数据从slave节点去拉取。另外,需要注意,Dleger会有他自己的CommitLog机制,也就是说,使用主从集群累计下来的消息,是无法转移到Dleger集群中的。 而如果要进行高可用的容灾备份,需要采用Dledger的方式来搭建高可用集群。注意,这个Dledger需要在RocketMQ4.5以后的版本才支持,我们使用的4.7.1版本已经默认集成了dledger。
搭建方法 要搭建高可用的Broker集群,我们只需要配置conf/dleger下的配置文件就行。这种模式是基于Raft协议的,是一个类似于Zookeeper的paxos协议的选举协议,也是会在集群中随机选举出一个leader,其他的就是follower。只是他选举的过程跟paxos有点不同。Raft协议基于随机休眠机制的,选举过程会比paxos相对慢一点。 首先:我们同样是需要修改runserver.sh和runbroker.sh,对JVM内存进行定制。 然后:我们需要修改conf/dleger下的配置文件。 跟dleger相关的几个配置项如下:
配置完后,同样是使用 nohup bin/mqbroker -c $conf_name & 的方式指定实例文件。在bin/dleger下有个fast-try.sh,这个脚本是在本地启动三个RocketMQ实例,搭建一个高可用的集群,读取的就是conf/dleger下的broker-no.conf,broker-n1.conf和broker-n2.conf。使用这个脚本同样要注意定制下JVM内存,他给每个实例默认定制的是1G内存,虚拟机肯定是不够的。这种单机三实例的集群搭建完成后,可以使用 bin/mqadmin clusterList -n worker1.conf的方式查看集群状态。
3.2.9 搭建Dledger高可用集群--了解
1、配置RocketMQ的JVM内存大小:之前提到过,在runserver.sh中需要定制nameserver的内存大小,在runbroker.sh中需要定制broker的内存大小。这些默认的配置可以认为都是经过检验的最优化配置,但是在实际情况中都还需要根据服务器的实际情况进行调整。这里以runbroker.sh中对G1GC的配置举例,在runbroker.sh中的关键配置:JAVA_OPT=\"${JAVA_OPT} -XX:+UseG1GC -XX:G1HeapRegionSize=16m -XX:G1ReservePercent=25 -XX:InitiatingHeapOccupancyPercent=30 -XX:SoftRefLRUPolicyMSPerMB=0\"JAVA_OPT=\"${JAVA_OPT} -verbose:gc -Xloggc:${GC_LOG_DIR}/rmq_broker_gc_%p_%t.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCApplicationStoppedTime -XX:+PrintAdaptiveSizePolicy\"JAVA_OPT=\"${JAVA_OPT} -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=30m\"-XX:+UseG1GC: 使用G1垃圾回收器, -XX:G1HeapRegionSize=16m 将G1的region块大小设为16M,-XX:G1ReservePercent:在G1的老年代中预留25%空闲内存,这个默认值是10%,RocketMQ把这个参数调大了。-XX:InitiatingHeapOccupancyPercent=30:当堆内存的使用率达到30%之后就会启动G1垃圾回收器尝试回收垃圾,默认值是45%,RocketMQ把这个参数调小了,也就是提高了GC的频率,但是避免了垃圾对象过多,一次垃圾回收时间太长的问题。然后,后面定制了GC的日志文件,确定GC日志文件的地址、打印的内容以及控制每个日志文件的大小为30M并且只保留5个文件。这些在进行性能检验时,是相当重要的参考内容。
2、RocketMQ的其他一些核心参数例如在conf/dleger/broker-n0.conf中有一个参数:sendMessageThreadPoolNums=16。这一个参数是表明RocketMQ内部用来发送消息的线程池的线程数量是16个,其实这个参数可以根据机器的CPU核心数进行适当调整,例如如果你的机器核心数超过16个,就可以把这个参数适当调大。
3、Linux内核参数定制我们在部署RocketMQ的时候,还需要对Linux内核参数进行一定的定制。例如ulimit,需要进行大量的网络通信和磁盘IO。vm.extra_free_kbytes,告诉VM在后台回收(kswapd)启动的阈值与直接回收(通过分配进程)的阈值之间保留额外的可用内存。RocketMQ使用此参数来避免内存分配中的长延迟。(与具体内核版本相关)vm.min_free_kbytes,如果将其设置为低于1024KB,将会巧妙的将系统破坏,并且系统在高负载下容易出现死锁。vm.max_map_count,限制一个进程可能具有的最大内存映射区域数。RocketMQ将使用mmap加载CommitLog和ConsumeQueue,因此建议将为此参数设置较大的值。vm.swappiness,定义内核交换内存页面的积极程度。较高的值会增加攻击性,较低的值会减少交换量。建议将值设置为10来避免交换延迟。File descriptor limits,RocketMQ需要为文件(CommitLog和ConsumeQueue)和网络连接打开文件描述符。我们建议设置文件描述符的值为655350。这些参数在CentOS7中的配置文件都在 /proc/sys/vm目录下。另外,RocketMQ的bin目录下有个os.sh里面设置了RocketMQ建议的系统内核参数,可以根据情况进行调整。
3.2.10 系统参数调优 -- 重要到这里,我们的整个RocketMQ的服务就搭建完成了。但是在实际使用时,我们说RocketMQ的吞吐量、性能都很高,那要发挥RocketMQ的高性能,还需要对RocketMQ以及服务器的性能进行定制
3.2、RocketMQ集群搭建与优化
三、RocketMQ集群架构
RocketMQ主要由 Producer、Broker、Consumer 三部分组成,其中Producer 负责生产消息,Consumer 负责消费消息,Broker 负责存储消息。Broker 在实际部署过程中对应一台服务器,每个 Broker 可以存储多个Topic的消息,每个Topic的消息也可以分片存储于不同的 Broker。Message Queue 用于存储消息的物理地址,每个Topic中的消息地址存储于多个 Message Queue 中。ConsumerGroup 由多个Consumer 实例构成。
1 消息模型(Message Model)
负责生产消息,一般由业务系统负责生产消息。一个消息生产者会把业务应用系统里产生的消息发送到broker服务器。RocketMQ提供多种发送方式,同步发送、异步发送、顺序发送、单向发送。同步和异步方式均需要Broker返回确认信息,单向发送不需要。 生产者中,会把同一类Producer组成一个集合,叫做生产者组。同一组的Producer被认为是发送同一类消息且发送逻辑一致。
2 消息生产者(Producer)
3 消息消费者(Consumer)
表示一类消息的集合,每个主题包含若干条消息,每条消息只能属于一个主题,是RocketMQ进行消息订阅的基本单位。 Topic只是一个逻辑概念,并不实际保存消息。同一个Topic下的消息,会分片保存到不同的Broker上,而每一个分片单位,就叫做MessageQueue。MessageQueue是一个具有FIFO特性的队列结构,生产者发送消息与消费者消费消息的最小单位。
4 主题(Topic)
消息中转角色,负责存储消息、转发消息。代理服务器在RocketMQ系统中负责接收从生产者发送来的消息并存储、同时为消费者的拉取请求作准备。代理服务器也存储消息相关的元数据,包括消费者组、消费进度偏移和主题和队列消息等。Broker Server是RocketMQ真正的业务核心,包含了多个重要的子模块:Remoting Module:整个Broker的实体,负责处理来自clients端的请求。Client Manager:负责管理客户端(Producer/Consumer)和维护Consumer的Topic订阅信息Store Service:提供方便简单的API接口处理消息存储到物理硬盘和查询功能。HA Service:高可用服务,提供Master Broker 和 Slave Broker之间的数据同步功能。Index Service:根据特定的Message key对投递到Broker的消息进行索引服务,以提供消息的快速查询。而Broker Server要保证高可用需要搭建主从集群架构。
RocketMQ中有两种Broker架构模式:普通集群:这种集群模式下会给每个节点分配一个固定的角色,master负责响应客户端的请求,并存储消息。slave则只负责对master的消息进行同步保存,并响应部分客户端的读请求。消息同步方式分为同步同步和异步同步。这种集群模式下各个节点的角色无法进行切换,也就是说,master节点挂了,这一组Broker就不可用了。Dledger高可用集群:Dledger是RocketMQ自4.5版本引入的实现高可用集群的一项技术。这个模式下的集群会随机选出一个节点作为master,而当master节点挂了后,会从slave中自动选出一个节点升级成为master。Dledger技术做的事情:1、从集群中选举出master节点 2、完成master节点往slave节点的消息同步。
5 代理服务器(Broker Server)
名称服务充当路由消息的提供者。Broker Server会在启动时向所有的Name Server注册自己的服务信息,并且后续通过心跳请求的方式保证这个服务信息的实时性。生产者或消费者能够通过名字服务查找各主题相应的Broker IP列表。多个Namesrv实例组成集群,但相互独立,没有信息交换。 这种特性也就意味着NameServer中任意的节点挂了,只要有一台服务节点正常,整个路由服务就不会有影响。当然,这里不考虑节点的负载情况。
6 名字服务(Name Server)
消息系统所传输信息的物理载体,生产和消费数据的最小单位,每条消息必须属于一个主题Topic。RocketMQ中每个消息拥有唯一的Message ID,且可以携带具有业务标识的Key。系统提供了通过Message ID和Key查询消息的功能。 并且Message上有一个为消息设置的标志,Tag标签。用于同一主题下区分不同类型的消息。来自同一业务单元的消息,可以根据不同业务目的在同一主题下设置不同标签。标签能够有效地保持代码的清晰度和连贯性,并优化RocketMQ提供的查询系统。消费者可以根据Tag实现对不同子主题的不同消费逻辑,实现更好的扩展性。
整体的基础概念如下图总结:
7 消息(Message)
四、RocketMQ消息转发模型
实战和集群架构
首先创建一个基于Maven的SpringBoot工程,引入如下依赖:<dependency> <groupId>org.apache.rocketmq</groupId> <artifactId>rocketmq-client</artifactId> <version>4.9.1</version></dependency>
另外还与一些依赖,例如openmessage、acl等扩展功能还需要添加对应的依赖。具体可以参见RocketMQ源码中的example模块。在RocketMQ源码包中的example模块提供了非常详尽的测试代码,也可以拿来直接调试。我们这里就用源码包中的示例来连接我们自己搭建的RocketMQ集群来进行演示。 但是在调试这些代码的时候要注意一个问题:这些测试代码中的生产者和消费者都需要依赖NameServer才能运行,只需要将NameServer指向我们自己搭建的RocketMQ集群,而不需要管Broker在哪里,就可以连接我们自己的自己的RocketMQ集群。
而RocketMQ提供的生产者和消费者寻找NameServer的方式有两种: 1、在代码中指定namesrvAddr属性。例如:consumer.setNamesrvAddr(\"127.0.0.1:9876\"); 2、通过NAMESRV_ADDR环境变量来指定。多个NameServer之间用分号连接。
1、测试环境搭建
消息发送者的固定步骤1.创建消息生产者producer,并制定生产者组名2.指定Nameserver地址3.启动producer4.创建消息对象,指定主题Topic、Tag和消息体5.发送消息6.关闭生产者producer
消息消费者的固定步骤1.创建消费者Consumer,制定消费者组名2.指定Nameserver地址3.订阅主题Topic和Tag4.设置回调函数,处理消息5.启动消费者consumer
2、RocketMQ的编程模型
同步发送
异步发送
1、同步发送消息的样例见:org.apache.rocketmq.example.simple.Producer等待消息返回后再继续进行下面的操作。 2、异步发送消息的样例见:org.apache.rocketmq.example.simple.AsyncProducer这个示例有个比较有趣的地方就是引入了一个countDownLatch来保证所有消息回调方法都执行完了再关闭Producer。 所以从这里可以看出,RocketMQ的Producer也是一个服务端,在往Broker发送消息的时候也要作为服务端提供服务。
3、单向发送消息的样例:public class OnewayProducer { public static void main(String[] args) throws Exception{ //Instantiate with a producer group name. DefaultMQProducer producer = new DefaultMQProducer(\"please_rename_unique_group_name\"); // Specify name server addresses. producer.setNamesrvAddr(\"localhost:9876\
拉模式
推模式
4、使用消费者消费消息。 消费者消费消息有两种模式,一种是消费者主动去Broker上拉取消息的拉模式,另一种是消费者等待Broker把消息推送过来的推模式。 拉模式的样例见:org.apache.rocketmq.example.simple.PullConsumer 推模式的样例见:org.apache.rocketmq.example.simple.PushConsumer通常情况下,用推模式比较简单。实际上RocketMQ的推模式也是由拉模式封装出来的。DefaultMQPullConsumerImpl这个消费者类已标记为过期,但是还是可以使用的。替换的类是DefaultLitePullConsumerImpl。
3.1 基本样例 基本样例部分我们使用消息生产者分别通过三种方式发送消息,同步发送、异步发送以及单向发送。 然后使用消费者来消费这些消息。
public class Producer { public static void main(String[] args) throws UnsupportedEncodingException { try { DefaultMQProducer producer = new DefaultMQProducer(\"please_rename_unique_group_name\"); producer.start(); for (int i = 0; i < 10; i++) { int orderId = i; for(int j = 0 ; j <= 5 ; j ++){ Message msg = new Message(\"OrderTopicTest\
顺序生产者
public class Consumer { public static void main(String[] args) throws MQClientException { DefaultMQPushConsumer consumer = new DefaultMQPushConsumer(\"please_rename_unique_group_name_3\");// consumer.setNamesrvAddr(\"localhost:9876\"); consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_LAST_OFFSET); consumer.subscribe(\"OrderTopicTest\
顺序消费者
3.2 顺序消息顺序消息生产者样例见:org.apache.rocketmq.example.order.Producer顺序消息消费者样例见:org.apache.rocketmq.example.order.Consumer验证时,可以启动多个Consumer实例,观察下每一个订单的消息分配以及每个订单下多个步骤的消费顺序。不管订单在多个Consumer实例之前是如何分配的,每个订单下的多条消息顺序都是固定从0~5的。RocketMQ保证的是消息的局部有序,而不是全局有序。先从控制台上看下List<MessageQueue> mqs是什么。再回看我们的样例,实际上,RocketMQ也只保证了每个OrderID的所有消息有序(发到了同一个queue),而并不能保证所有消息都有序。所以这就涉及到了RocketMQ消息有序的原理。要保证最终消费到的消息是有序的,需要从Producer、Broker、Consumer三个步骤都保证消息有序才行。首先在发送者端:在默认情况下,消息发送者会采取Round Robin轮询方式把消息发送到不同的MessageQueue(分区队列),而消费者消费的时候也从多个MessageQueue上拉取消息,这种情况下消息是不能保证顺序的。而只有当一组有序的消息发送到同一个MessageQueue上时,才能利用MessageQueue先进先出的特性保证这一组消息有序。而Broker中一个队列内的消息是可以保证有序的。然后在消费者端:消费者会从多个消息队列上去拿消息。这时虽然每个消息队列上的消息是有序的,但是多个队列之间的消息仍然是乱序的。消费者端要保证消息有序,就需要按队列一个一个来取消息,即取完一个队列的消息后,再去取下一个队列的消息。而给consumer注入的MessageListenerOrderly对象,在RocketMQ内部就会通过锁队列的方式保证消息是一个一个队列来取的。MessageListenerConcurrently这个消息监听器则不会锁队列,每次都是从多个Message中取一批数据(默认不超过32条)。因此也无法保证消息有序。
3.3 广播消息 广播消息的消息生产者样例见:org.apache.rocketmq.example.broadcast.PushConsumer广播消息并没有特定的消息消费者样例,这是因为这涉及到消费者的集群消费模式。在集群状态(MessageModel.CLUSTERING)下,每一条消息只会被同一个消费者组中的一个实例消费到(这跟kafka和rabbitMQ的集群模式是一样的)。而广播模式则是把消息发给了所有订阅了对应主题的消费者,而不管消费者是不是同一个消费者组。
3.4 延迟消息 延迟消息的生产者案例 public class ScheduledMessageProducer { public static void main(String[] args) throws Exception { // Instantiate a producer to send scheduled messages DefaultMQProducer producer = new DefaultMQProducer(\"ExampleProducerGroup\"); // Launch producer producer.start(); int totalMessagesToSend = 100; for (int i = 0; i < totalMessagesToSend; i++) { Message message = new Message(\"TestTopic\
public class SimpleBatchProducer { public static void main(String[] args) throws Exception { DefaultMQProducer producer = new DefaultMQProducer(\"BatchProducerGroupName\
SimpleBatchProducer
public class SplitBatchProducer { public static void main(String[] args) throws Exception { DefaultMQProducer producer = new DefaultMQProducer(\"BatchProducerGroupName\"); producer.start(); //large batch String topic = \"BatchTest\
SplitBatchProducer
3.5 批量消息批量消息是指将多条消息合并成一个批量消息,一次发送出去。这样的好处是可以减少网络IO,提升吞吐量。批量消息的消息生产者样例见:org.apache.rocketmq.example.batch.SimpleBatchProducer和org.apache.rocketmq.example.batch.SplitBatchProducer相信大家在官网以及测试代码中都看到了关键的注释:如果批量消息大于1MB就不要用一个批次发送,而要拆分成多个批次消息发送。也就是说,一个批次消息的大小不要超过1MB实际使用时,这个1MB的限制可以稍微扩大点,实际最大的限制是4194304字节,大概4MB。但是使用批量消息时,这个消息长度确实是必须考虑的一个问题。而且批量消息的使用是有一定限制的,这些消息应该有相同的Topic,相同的waitStoreMsgOK。而且不能是延迟消息、事务消息等。
public class TagFilterProducer { public static void main(String[] args) throws Exception { DefaultMQProducer producer = new DefaultMQProducer(\"please_rename_unique_group_name\"); producer.start(); String[] tags = new String[] {\"TagA\
TagFilterProducer
TagFilterConsumer
public class SqlFilterProducer { public static void main(String[] args) throws Exception { DefaultMQProducer producer = new DefaultMQProducer(\"please_rename_unique_group_name\"); producer.start(); String[] tags = new String[] {\"TagA\
SqlFilterProducer
public class SqlFilterConsumer { public static void main(String[] args) throws Exception { DefaultMQPushConsumer consumer = new DefaultMQPushConsumer(\"please_rename_unique_group_name\"); // Don't forget to set enablePropertyFilter=true in broker consumer.subscribe(\"SqlFilterTest\
3.6 过滤消息在大多数情况下,可以使用Message的Tag属性来简单快速的过滤信息。使用Tag过滤消息的消息生产者案例见:org.apache.rocketmq.example.filter.TagFilterProducer使用Tag过滤消息的消息消费者案例见:org.apache.rocketmq.example.filter.TagFilterConsumer主要是看消息消费者。consumer.subscribe(\"TagFilterTest\
然后,我们要了解下事务消息的使用限制: 1、事务消息不支持延迟消息和批量消息。 2、为了避免单个消息被检查太多次而导致半队列消息累积,我们默认将单个消息的检查次数限制为 15 次,但是用户可以通过 Broker 配置文件的 transactionCheckMax参数来修改此限制。如果已经检查某条消息超过 N 次的话( N = transactionCheckMax ) 则 Broker 将丢弃此消息,并在默认情况下同时打印错误日志。用户可以通过重写 AbstractTransactionCheckListener 类来修改这个行为。 3、事务消息将在 Broker 配置文件中的参数 transactionMsgTimeout 这样的特定时间长度之后被检查。当发送事务消息时,用户还可以通过设置用户属性 CHECK_IMMUNITY_TIME_IN_SECONDS 来改变这个限制,该参数优先于 transactionMsgTimeout 参数。 4、事务性消息可能不止一次被检查或消费。 5、提交给用户的目标主题消息可能会失败,目前这依日志的记录而定。它的高可用性通过 RocketMQ 本身的高可用性机制来保证,如果希望确保事务消息不丢失、并且事务完整性得到保证,建议使用同步的双重写入机制。 6、事务消息的生产者 ID 不能与其他类型消息的生产者 ID 共享。与其他类型的消息不同,事务消息允许反向查询、MQ服务器能通过它们的生产者 ID 查询到消费者。
接下来,我们还要了解下事务消息的实现机制,参见下图:事务消息机制的关键是在发送消息时,会将消息转为一个half半消息,并存入RocketMQ内部的一个 RMQ_SYS_TRANS_HALF_TOPIC 这个Topic,这样对消费者是不可见的。再经过一系列事务检查通过后,再将消息转存到目标Topic,这样对消费者就可见了。 最后,我们还需要思考下事务消息的作用。 大家想一下这个事务消息跟分布式事务有什么关系?为什么扯到了分布式事务相关的两阶段提交上了?事务消息只保证了发送者本地事务和发送消息这两个操作的原子性,但是并不保证消费者本地事务的原子性,所以,事务消息只保证了分布式事务的一半。但是即使这样,对于复杂的分布式事务,RocketMQ提供的事务消息也是目前业内最佳的降级方案。
3.7 事务消息这个事务消息是RocketMQ提供的一个非常有特色的功能,需要着重理解。 首先,我们了解下什么是事务消息。官网的介绍是:事务消息是在分布式系统中保证最终一致性的两阶段提交的消息实现。他可以保证本地事务执行与消息发送两个操作的原子性,也就是这两个操作一起成功或者一起失败。 其次,我们来理解下事务消息的编程模型。事务消息只保证消息发送者的本地事务与发消息这两个操作的原子性,因此,事务消息的示例只涉及到消息发送者,对于消息消费者来说,并没有什么特别的。
ACL客户端可以参考:org.apache.rocketmq.example.simple包下面的AclClient代码。注意,如果要在自己的客户端中使用RocketMQ的ACL功能,还需要引入一个单独的依赖包<dependency> <groupId>org.apache.rocketmq</groupId> <artifactId>rocketmq-acl</artifactId> <version>4.9.1</version></dependency> 而Broker端具体的配置信息可以参见源码包下docs/cn/acl/user_guide.md。主要是在broker.conf中打开acl的标志:aclEnable=true。然后就可以用plain_acl.yml来进行权限配置了。并且这个配置文件是热加载的,也就是说要修改配置时,只要修改配置文件就可以了,不用重启Broker服务。
3.8 ACL权限控制 权限控制(ACL)主要为RocketMQ提供Topic资源级别的用户访问控制。用户在使用RocketMQ权限控制时,可以在Client客户端通过 RPCHook注入AccessKey和SecretKey签名;同时,将对应的权限控制属性(包括Topic访问权限、IP白名单和AccessKey和SecretKey签名等)设置在$ROCKETMQ_HOME/conf/plain_acl.yml的配置文件中。Broker端对AccessKey所拥有的权限进行校验,校验不过,抛出异常;
3、RocketMQ的消息样例
RocketMQ原生API使用
这部分我们看下SpringBoot如何快速集成RocketMQ。在使用SpringBoot的starter集成包时,要特别注意版本。因为SpringBoot集成RocketMQ的starter依赖是由Spring社区提供的,目前正在快速迭代的过程当中,不同版本之间的差距非常大,甚至基础的底层对象都会经常有改动。例如如果使用rocketmq-spring-boot-starter:2.0.4版本开发的代码,升级到目前最新的rocketmq-spring-boot-starter:2.1.1后,基本就用不了了。我们创建一个maven工程,引入关键依赖:<dependencies> <dependency> <groupId>org.apache.rocketmq</groupId> <artifactId>rocketmq-spring-boot-starter</artifactId> <version>2.1.1</version> <exclusions> <exclusion> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </exclusion> <exclusion> <groupId>org.springframework</groupId> <artifactId>spring-core</artifactId> </exclusion> <exclusion> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> </exclusion> </exclusions> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <version>2.1.6.RELEASE</version> </dependency> <dependency> <groupId>io.springfox</groupId> <artifactId>springfox-swagger-ui</artifactId> <version>2.9.2</version> </dependency> <dependency> <groupId>io.springfox</groupId> <artifactId>springfox-swagger2</artifactId> <version>2.9.2</version> </dependency> </dependencies>rocketmq-spring-boot-starter:2.1.1引入的SpringBoot包版本是2.0.5.RELEASE,这里把SpringBoot的依赖包升级了一下。
配置文件 application.properties#NameServer地址rocketmq.name-server=192.168.232.128:9876#默认的消息生产者组rocketmq.producer.group=springBootGroup
消息消费者package com.roy.rocket.basic;import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;import org.apache.rocketmq.spring.core.RocketMQListener;import org.springframework.stereotype.Component;/** * @author :楼兰 * @description: **/@Component@RocketMQMessageListener(consumerGroup = \"MyConsumerGroup\
然后关于事务消息,还需要配置一个事务消息监听器:package com.roy.rocket.config;import org.apache.commons.lang3.StringUtils;import org.apache.rocketmq.client.producer.LocalTransactionState;import org.apache.rocketmq.spring.annotation.RocketMQTransactionListener;import org.apache.rocketmq.spring.core.RocketMQLocalTransactionListener;import org.apache.rocketmq.spring.core.RocketMQLocalTransactionState;import org.apache.rocketmq.spring.support.RocketMQUtil;import org.springframework.messaging.Message;import org.springframework.messaging.converter.StringMessageConverter;import java.util.concurrent.ConcurrentHashMap;/** * @author :楼兰 * @description: **/@RocketMQTransactionListener(rocketMQTemplateBeanName = \"rocketMQTemplate\
这样我们启动应用后,就能够通过访问 http://localhost:8080/MQTest/sendMessage?message=123 接口来发送一条简单消息。并在SpringConsumer中消费到。也可以通过访问http://localhost:8080/MQTest/sendTransactionMessage?message=123 ,来发送一条事务消息。这里可以看到,对事务消息,SpringBoot进行封装时,就缺少了transactionId,这在事务控制中是非常关键的。
1、快速实战
2、其他更多消息类型:对于其他的消息类型,文档中就不一一记录了。具体可以参见源码中的junit测试案例。
3、总结:SpringBoot 引入org.apache.rocketmq:rocketmq-spring-boot-starter依赖后,就可以通过内置的RocketMQTemplate来与RocketMQ交互。相关属性都以rockemq.开头。具体所有的配置信息可以参见org.apache.rocketmq.spring.autoconfigure.RocketMQProperties这个类。SpringBoot依赖中的Message对象和RocketMQ-client中的Message对象是两个不同的对象,这在使用的时候要非常容易弄错。例如RocketMQ-client中的Message里的TAG属性,在SpringBoot依赖中的Message中就没有。Tag属性被移到了发送目标中,与Topic一起,以Topic:Tag的方式指定。最后强调一次,一定要注意版本。rocketmq-spring-boot-starter的更新进度一般都会略慢于RocketMQ的版本更新,并且版本不同会引发很多奇怪的问题。apache有一个官方的rocketmq-spring示例,地址:https://github.com/apache/rocketmq-spring.git 以后如果版本更新了,可以参考下这个示例代码。
SpringBoot整合RocketMQ
创建Maven工程,引入依赖:<dependencies> <dependency> <groupId>org.apache.rocketmq</groupId> <artifactId>rocketmq-client</artifactId> <version>4.9.1</version> </dependency> <dependency> <groupId>org.apache.rocketmq</groupId> <artifactId>rocketmq-acl</artifactId> <version>4.9.1</version> </dependency> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-stream-rocketmq</artifactId> <version>2.2.3.RELEASE</version> <exclusions> <exclusion> <groupId>org.apache.rocketmq</groupId> <artifactId>rocketmq-client</artifactId> </exclusion> <exclusion> <groupId>org.apache.rocketmq</groupId> <artifactId>rocketmq-acl</artifactId> </exclusion> </exclusions> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <version>2.3.3.RELEASE</version> </dependency> </dependencies>
然后增加配置文件application.properties#ScStream通用的配置以spring.cloud.stream开头spring.cloud.stream.bindings.input.destination=TestTopicspring.cloud.stream.bindings.input.group=scGroupspring.cloud.stream.bindings.output.destination=TestTopic#rocketMQ的个性化配置以spring.cloud.stream.rocketmq开头#spring.cloud.stream.rocketmq.binder.name-server=192.168.232.128:9876;192.168.232.129:9876;192.168.232.130:9876spring.cloud.stream.rocketmq.binder.name-server=192.168.232.128:9876SpringCloudStream中,一个binding对应一个消息通道。这其中配置的input,是在Sink.class中定义的,对应一个消息消费者。而output,是在Source.class中定义的,对应一个消息生产者。
然后就可以增加消息消费者:package com.roy.scrocket.basic;import org.springframework.cloud.stream.annotation.StreamListener;import org.springframework.cloud.stream.messaging.Sink;import org.springframework.stereotype.Component;/** * @author :楼兰 * @description: **/@Componentpublic class ScConsumer { @StreamListener(Sink.INPUT) public void onMessage(String messsage){ System.out.println(\"received message:\"+messsage+\" from binding:\"+ Sink.INPUT); }}
最后增加一个Controller类用于测试:package com.roy.scrocket.controller;import com.roy.scrocket.basic.ScProducer;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;import javax.annotation.Resource;/** * @author :楼兰 * @description: **/@RestController@RequestMapping(\"/MQTest\")public class MQTestController { @Resource private ScProducer producer; @RequestMapping(\"/sendMessage\") public String sendMessage(String message){ producer.sendMessage(message); return \"消息发送完成\"; }}启动应用后,就可以访问http://localhost:8080/MQTest/sendMessage?message=123,给RocketMQ发送一条消息到TestTopic,并在ScConsumer中消费到了。
2、总结关于SpringCloudStream框架。这是一套几乎通用的消息中间件编程框架,从对接RocketMQ换到对接Kafka,业务代码几乎不需要动,只需要更换pom依赖并且修改配置文件就行了。但是,由于各个MQ产品都有自己的业务模型,差距非常大,所以使用使用SpringCloudStream时要注意业务模型转换。并且在实际使用中,要非常注意各个MQ的个性化配置属性。例如RocketMQ的个性化属性都是以spring.cloud.stream.rocketmq开头,只有通过这些属性才能用上RocketMQ的延迟消息、排序消息、事务消息等个性化功能。关于SpringCloudStream框架下的RocketMQ客户端版本:RocketMQ的SpringCloudStream依赖是交由厂商自己维护的,也就是由阿里巴巴自己来维护。这个维护力度与Spring社区相比还是有不小差距的。所以一方面可以看到之前在使用SpringBoot时着重强调的版本问题,在使用SpringCloudStream中被放大了很多。示例中的spring-cloud-starter-stream-rocketmq的依赖版本是2.2.7.RELEASE,而其中包含的rocketmq-client版本还是4.6.1。这个差距就非常大了。rocketmq相关的适配版本,目前来看,还是跟不上的。SpringCloudStreamStream框架本身也在不断演进。从SpringCloudStream3.1.x版本开始,就不再推荐使用@EnableBinding注解来声明Binding了,转而推荐使用Consumer,Supplier,Function这一系列函数式编程组件来声明Binding,这其中又会引入响应式编程的一系列编程风格。从目前来看,有点复杂,不太建议使用新版本 。
SpringCloudStream整合RocketMQ
RocketMq使用
我们考虑一个通用的MQ场景:其中,1,2,4三个场景都是跨网络的,而跨网络就肯定会有丢消息的可能。然后关于3这个环节,通常MQ存盘时都会先写入操作系统的缓存page cache中,然后再由操作系统异步的将消息写入硬盘。这个中间有个时间差,就可能会造成消息丢失。如果服务挂了,缓存中还没有来得及写入硬盘的消息就会丢失。这个是MQ场景都会面对的通用的丢消息问题。那我们看看用RocketMQ时要如何解决这个问题
1、哪些环节会有丢消息的可能?
1、为什么要发送个half消息?有什么用?这个half消息是在订单系统进行下单操作前发送,并且对下游服务的消费者是不可见的。那这个消息的作用更多的体现在确认RocketMQ的服务是否正常。相当于嗅探下RocketMQ服务是否正常,并且通知RocketMQ,我马上就要发一个很重要的消息了,你做好准备。
2.half消息如果写入失败了怎么办?如果没有half消息这个流程,那我们通常是会在订单系统中先完成下单,再发送消息给MQ。这时候写入消息到MQ如果失败就会非常尴尬了。而half消息如果写入失败,我们就可以认为MQ的服务是有问题的,这时,就不能通知下游服务了。我们可以在下单时给订单一个状态标记,然后等待MQ服务正常后再进行补偿操作,等MQ服务正常后重新下单通知下游服务。
3.订单系统写数据库失败了怎么办?这个问题我们同样比较下没有使用事务消息机制时会怎么办?如果没有使用事务消息,我们只能判断下单失败,抛出了异常,那就不往MQ发消息了,这样至少保证不会对下游服务进行错误的通知。但是这样的话,如果过一段时间数据库恢复过来了,这个消息就无法再次发送了。当然,也可以设计另外的补偿机制,例如将订单数据缓存起来,再启动一个线程定时尝试往数据库写。而如果使用事务消息机制,就可以有一种更优雅的方案。如果下单时,写数据库失败(可能是数据库崩了,需要等一段时间才能恢复)。那我们可以另外找个地方把订单消息先缓存起来(Redis、文本或者其他方式),然后给RocketMQ返回一个UNKNOWN状态。这样RocketMQ就会过一段时间来回查事务状态。我们就可以在回查事务状态时再尝试把订单数据写入数据库,如果数据库这时候已经恢复了,那就能完整正常的下单,再继续后面的业务。这样这个订单的消息就不会因为数据库临时崩了而丢失。
4.half消息写入成功后RocketMQ挂了怎么办?我们需要注意下,在事务消息的处理机制中,未知状态的事务状态回查是由RocketMQ的Broker主动发起的。也就是说如果出现了这种情况,那RocketMQ就不会回调到事务消息中回查事务状态的服务。这时,我们就可以将订单一直标记为\"新下单\"的状态。而等RocketMQ恢复后,只要存储的消息没有丢失,RocketMQ就会再次继续状态回查的流程。
5.下单成功后如何优雅的等待支付成功?在订单场景下,通常会要求下单完成后,客户在一定时间内,例如10分钟,内完成订单支付,支付完成后才会通知下游服务进行进一步的营销补偿。如果不用事务消息,那通常会怎么办?最简单的方式是启动一个定时任务,每隔一段时间扫描订单表,比对未支付的订单的下单时间,将超过时间的订单回收。这种方式显然是有很大问题的,需要定时扫描很庞大的一个订单信息,这对系统是个不小的压力。那更进一步的方案是什么呢?是不是就可以使用RocketMQ提供的延迟消息机制。往MQ发一个延迟1分钟的消息,消费到这个消息后去检查订单的支付状态,如果订单已经支付,就往下游发送下单的通知。而如果没有支付,就再发一个延迟1分钟的消息。最终在第十个消息时把订单回收。这个方案就不用对全部的订单表进行扫描,而只需要每次处理一个单独的订单消息。那如果使用上了事务消息呢?我们就可以用事务消息的状态回查机制来替代定时的任务。在下单时,给Broker返回一个UNKNOWN的未知状态。而在状态回查的方法中去查询订单的支付状态。这样整个业务逻辑就会简单很多。我们只需要配置RocketMQ中的事务消息回查次数(默认15次)和事务回查间隔时间(messageDelayLevel),就可以更优雅的完成这个支付状态检查的需求。
6、事务消息机制的作用整体来说,在订单这个场景下,消息不丢失的问题实际上就还是转化成了下单这个业务与下游服务的业务的分布式事务一致性问题。而事务一致性问题一直以来都是一个非常复杂的问题。而RocketMQ的事务消息机制,实际上只保证了整个事务消息的一半,他保证的是订单系统下单和发消息这两个事件的事务一致性,而对下游服务的事务并没有保证。但是即便如此,也是分布式事务的一个很好的降级方案。目前来看,也是业内最好的降级方案。
这个结论比较容易理解,因为RocketMQ的事务消息机制就是为了保证零丢失来设计的,并且经过阿里的验证,肯定是非常靠谱的。 但是如果深入一点的话,我们还是要理解下这个事务消息到底是不是靠谱。我们以最常见的电商订单场景为例,来简单分析下事务消息机制如何保证消息不丢失。我们看下下面这个流程图:
1》 生产者使用事务消息机制保证消息零丢失
1、同步刷盘这个从我们之前的分析,就很好理解了。我们可以简单的把RocketMQ的刷盘方式 flushDiskType配置成同步刷盘就可以保证消息在刷盘过程中不会丢失了。
2、Dledger的文件同步在使用Dledger技术搭建的RocketMQ集群中,Dledger会通过两阶段提交的方式保证文件在主从之间成功同步。简单来说,数据同步会通过两个阶段,一个是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协议完成了两阶段的数据同步。
2》RocketMQ配置同步刷盘+Dledger主从架构保证MQ主从同步时不会丢消息
正常情况下,消费者端都是需要先处理本地事务,然后再给MQ一个ACK响应,这时MQ就会修改Offset,将消息标记为已消费,从而不再往其他消费者推送消息。所以在Broker的这种重新推送机制下,消息是不会在传输过程中丢失的。但是也会有下面这种情况会造成服务端消息丢失:DefaultMQPushConsumer consumer = new DefaultMQPushConsumer(\"please_rename_unique_group_name_4\
3》消费者端不要使用异步消费机制
NameServer在RocketMQ中,是扮演的一个路由中心的角色,提供到Broker的路由功能。但是其实路由中心这样的功能,在所有的MQ中都是需要的。kafka是用zookeeper和一个作为Controller的Broker一起来提供路由服务,整个功能是相当复杂纠结的。而RabbitMQ是由每一个Broker来提供路由服务。而只有RocketMQ把这个路由中心单独抽取了出来,并独立部署。这个NameServer之前都了解过,集群中任意多的节点挂掉,都不会影响他提供的路由功能。那如果集群中所有的NameServer节点都挂了呢?有很多人就会认为在生产者和消费者中都会有全部路由信息的缓存副本,那整个服务可以正常工作一段时间。其实这个问题大家可以做一下实验,当NameServer全部挂了后,生产者和消费者是立即就无法工作了的。至于为什么,可以去源码中找找答案。那再回到我们的消息不丢失的问题,在这种情况下,RocketMQ相当于整个服务都不可用了,那他本身肯定无法给我们保证消息不丢失了。我们只能自己设计一个降级方案来处理这个问题了。例如在订单系统中,如果多次尝试发送RocketMQ不成功,那就只能另外找给地方(Redis、文件或者内存等)把订单消息缓存下来,然后起一个线程定时的扫描这些失败的订单消息,尝试往RocketMQ发送。这样等RocketMQ的服务恢复过来后,就能第一时间把这些消息重新发送出去。整个这套降级的机制,在大型互联网项目中,都是必须要有的。
4》RocketMQ特有的问题,NameServer挂了如何保证消息不丢失?
完整分析过后,整个RocketMQ消息零丢失的方案其实挺简单生产者使用事务消息机制。Broker配置同步刷盘+Dledger主从架构消费者不要使用异步消费。整个MQ挂了之后准备降级方案那这套方案是不是就很完美呢?其实很明显,这整套的消息零丢失方案,在各个环节都大量的降低了系统的处理性能以及吞吐量。在很多场景下,这套方案带来的性能损失的代价可能远远大于部分消息丢失的代价。所以,我们在设计RocketMQ使用方案时,要根据实际的业务情况来考虑。例如,如果针对所有服务器都在同一个机房的场景,完全可以把Broker配置成异步刷盘来提升吞吐量。而在有些对消息可靠性要求没有那么高的场景,在生产者端就可以采用其他一些更简单的方案来提升吞吐,而采用定时对账、补偿的机制来提高消息的可靠性。而如果消费者不需要进行消息存盘,那使用异步消费的机制带来的性能提升也是非常显著的。总之,这套消息零丢失方案的总结是为了在设计RocketMQ使用方案时的一个很好的参考。
5》RocketMQ消息零丢失方案总结
2、RocketMQ消息零丢失方案
4.1、使用RocketMQ如何保证消息不丢失?
这个也是面试时最常见的问题,需要对MQ场景有一定的深入理解。例如如果我们有个大数据系统,需要对业务系统的日志进行收集分析,这时候为了减少对业务系统的影响,通常都会通过MQ来做消息中转。而这时候,对消息的顺序就有一定的要求了。例如我们考虑下面这一系列的操作。用户的积分默认是0分,而新注册用户设置为默认的10分。用户有奖励行为,积分+2分。用户有不正当行为,积分-3分。这样一组操作,正常用户积分要变成9分。但是如果顺序乱了,这个结果就全部对不了。这时,就需要对这一组操作,保证消息都是有序的。
1、为什么要保证消息有序?
首先 我们需要分析下这个问题,在通常的业务场景中,全局有序和局部有序哪个更重要?其实在大部分的MQ业务场景,我们只需要能够保证局部有序就可以了。例如我们用QQ聊天,只需要保证一个聊天窗口里的消息有序就可以了。而对于电商订单场景,也只要保证一个订单的所有消息是有序的就可以了。至于全局消息的顺序,并不会太关心。而通常意义下,全局有序都可以压缩成局部有序的问题。例如以前我们常用的聊天室,就是个典型的需要保证消息全局有序的场景。但是这种场景,通常可以压缩成只有一个聊天窗口的QQ来理解。即整个系统只有一个聊天通道,这样就可以用QQ那种保证一个聊天窗口消息有序的方式来保证整个系统的全局消息有序。
然后 落地到RocketMQ。通常情况下,发送者发送消息时,会通过MessageQueue轮询的方式保证消息尽量均匀的分布到所有的MessageQueue上,而消费者也就同样需要从多个MessageQueue上消费消息。而MessageQueue是RocketMQ存储消息的最小单元,他们之间的消息都是互相隔离的,在这种情况下,是无法保证消息全局有序的。
而对于局部有序的要求,只需要将有序的一组消息都存入同一个MessageQueue里,这样MessageQueue的FIFO设计天生就可以保证这一组消息的有序。RocketMQ中,可以在发送者发送消息时指定一个MessageSelector对象,让这个对象来决定消息发入哪一个MessageQueue。这样就可以保证一组有序的消息能够发到同一个MessageQueue里。 另外,通常所谓的保证Topic全局消息有序的方式,就是将Topic配置成只有一个MessageQueue队列(默认是4个)。这样天生就能保证消息全局有序了。这个说法其实就是我们将聊天室场景压缩成只有一个聊天窗口的QQ一样的理解方式。而这种方式对整个Topic的消息吞吐影响是非常大的,如果这样用,基本上就没有用MQ的必要了。
MQ的顺序问题分为全局有序和局部有序。全局有序:整个MQ系统的所有消息严格按照队列先入先出顺序进行消费。局部有序:只保证一部分关键消息的消费顺序。
2、如何保证消息有序?
4.2、使用RocketMQ如何保证消息顺序
在正常情况下,使用MQ都会要尽量保证他的消息生产速度和消费速度整体上是平衡的,但是如果部分消费者系统出现故障,就会造成大量的消息积累。这类问题通常在实际工作中会出现得比较隐蔽。例如某一天一个数据库突然挂了,大家大概率就会集中处理数据库的问题。等好不容易把数据库恢复过来了,这时基于这个数据库服务的消费者程序就会积累大量的消息。或者网络波动等情况,也会导致消息大量的积累。这在一些大型的互联网项目中,消息积压的速度是相当恐怖的。所以消息积压是个需要时时关注的问题。 对于消息积压,如果是RocketMQ或者kafka还好,他们的消息积压不会对性能造成很大的影响。而如果是RabbitMQ的话,那就惨了,大量的消息积压可以瞬间造成性能直线下滑。 对于RocketMQ来说,有个最简单的方式来确定消息是否有积压。那就是使用web控制台,就能直接看到消息的积压情况。
在Web控制台的主题页面,可以通过 Consumer管理 按钮实时看到消息的积压情况。
另外,也可以通过mqadmin指令在后台检查各个Topic的消息延迟情况。还有RocketMQ也会在他的 ${storePathRootDir}/config 目录下落地一系列的json文件,也可以用来跟踪消息积压情况。
1、如何确定RocketMQ有大量的消息积压?
其实我们回顾下RocketMQ的负载均衡的内容就不难想到解决方案。如果Topic下的MessageQueue配置得是足够多的,那每个Consumer实际上会分配多个MessageQueue来进行消费。这个时候,就可以简单的通过增加Consumer的服务节点数量来加快消息的消费,等积压消息消费完了,再恢复成正常情况。最极限的情况是把Consumer的节点个数设置成跟MessageQueue的个数相同。但是如果此时再继续增加Consumer的服务节点就没有用了。而如果Topic下的MessageQueue配置得不够多的话,那就不能用上面这种增加Consumer节点个数的方法了。这时怎么办呢? 这时如果要快速处理积压的消息,可以创建一个新的Topic,配置足够多的MessageQueue。然后把所有消费者节点的目标Topic转向新的Topic,并紧急上线一组新的消费者,只负责消费旧Topic中的消息,并转储到新的Topic中,这个速度是可以很快的。然后在新的Topic上,就可以通过增加消费者个数来提高消费速度了。之后再根据情况恢复成正常情况。
在官网中,还分析了一个特殊的情况。就是如果RocketMQ原本是采用的普通方式搭建主从架构,而现在想要中途改为使用Dledger高可用集群,这时候如果不想历史消息丢失,就需要先将消息进行对齐,也就是要消费者把所有的消息都消费完,再来切换主从架构。因为Dledger集群会接管RocketMQ原有的CommitLog日志,所以切换主从架构时,如果有消息没有消费完,这些消息是存在旧的CommitLog中的,就无法再进行消费了。这个场景下也是需要尽快的处理掉积压的消息。
2、如何处理大量积压的消息?
4.3、使用RocketMQ如何快速处理积压消息?
2、消息轨迹配置打开消息轨迹功能,需要在broker.conf中打开一个关键配置:traceTopicEnable=true这个配置的默认值是false。也就是说默认是关闭的。
4.4、RocketMQ的消息轨迹
RocketMQ使用中常见的问题
在RocketMQ的管理控制台创建Topic时,可以看到要单独设置读队列和写队列。通常在运行时,都需要设置读队列=写队列。perm字段表示Topic的权限。有三个可选项。 2:禁写禁订阅,4:可订阅,不能写,6:可写可订阅
这其中,写队列会真实的创建对应的存储文件,负责消息写入。而读队列会记录Consumer的Offset,负责消息读取。这其实是一种读写分离的思想。RocketMQ在最MessageQueue的路由策略时,就可以通过指向不同的队列来实现读写分离。 在往写队列里写Message时,会同步写入到一个对应的读队列中。
这时,如果写队列大于读队列,就会有一部分写队列无法写入到读队列中,这\\的消息就无法被读取,就会造成消息丢失。--消息存入了,但是读不出来。
而如果反过来,写队列小于读队列,那就有一部分读队列里是没有消息写入的。如果有一个消费者被分配的是这些没有消息的读队列,那这些消费者就无法消费消息,造成消费者空转,极大的浪费性能。
从这里可以看到,写队列>读队列,会造成消息丢失,写队列<读队列,又会造成消费者空转。所以,在使用时,都是要求 写队列=读队列。 只有一种情况下可以考虑将读写队列设置为不一致,就是要对Topic的MessageQueue进行缩减的时候。例如原来四个队列,现在要缩减成两个队列。如果立即缩减读写队列,那么被缩减的MessageQueue上没有被消费的消息,就会丢失。这时,可以先缩减写队列,待空出来的读队列上的消息都被消费完了之后,再来缩减读队列,这样就可以比较平稳的实现队列缩减了。
一、读队列与写队列
RocketMQ消息直接采用磁盘文件保存消息,默认路径在${user_home}/store目录。这些存储目录可以在broker.conf中自行指定。
存储文件主要分为三个部分:CommitLog:存储消息的元数据。所有消息都会顺序存入到CommitLog文件当中。CommitLog由多个文件组成,每个文件固定大小1G。以第一条消息的偏移量为文件名。ConsumerQueue:存储消息在CommitLog的索引。一个MessageQueue一个文件,记录当前MessageQueue被哪些消费者组消费到了哪一条CommitLog。IndexFile:为了消息查询提供了一种通过key或时间区间来查询消息的方法,这种通过IndexFile来查找消息的方法不影响发送与消费消息的主流程
另外,还有几个辅助的存储文件:checkpoint:数据存盘检查点。里面主要记录commitlog文件、ConsumeQueue文件以及IndexFile文件最后一次刷盘的时间戳。config/*.json:这些文件是将RocketMQ的一些关键配置信息进行存盘保存。例如Topic配置、消费者组配置、消费者组消息偏移量Offset 等等一些信息。abort:这个文件是RocketMQ用来判断程序是否正常关闭的一个标识文件。正常情况下,会在启动时创建,而关闭服务时删除。但是如果遇到一些服务器宕机,或者kill -9这样一些非正常关闭服务的情况,这个abort文件就不会删除,因此RocketMQ就可以判断上一次服务是非正常关闭的,后续就会做一些数据恢复的操作。
1、CommitLog文件存储所有消息实体。所有生产者发过来的消息,都会无差别的依次存储到Commitlog文件当中。这样的好处是可以减少查找目标文件的时间,让消息以最快的速度落盘。对比Kafka存文件时,需要寻找消息所属的Partition文件,再完成写入,当Topic比较多时,这样的Partition寻址就会浪费比较多的时间,所以Kafka不太适合多Topic的场景。而RocketMQ的这种快速落盘的方式在多Topic场景下,优势就比较明显。 **文件结构:**CommitLog的文件大小是固定的,但是其中存储的每个消息单元长度是不固定的,具体格式可以参考org.apache.rocketmq.store.CommitLogimage 正因为消息的记录大小不固定,所以RocketMQ在每次存CommitLog文件时,都会去检查当前CommitLog文件空间是否足够,如果不够的话,就重新创建一个CommitLog文件。文件名为当前消息的偏移量。在后面的源码中去验证。
2、ConsumeQueue文件主要是加速消费者的消息索引。他的每个文件夹对应RocketMQ中的一个MessageQueue,文件夹下的文件记录了每个MessageQueue中的消息在CommitLog文件当中的偏移量。这样,消费者通过ComsumeQueue文件,就可以快速找到CommitLog文件中感兴趣的消息记录。而消费者在ConsumeQueue文件当中的消费进度,会保存在config/consumerOffset.json文件当中。 **文件结构:**每个ConsumeQueue文件固定由30万个固定大小20byte的数据块组成,数据块的内容包括:msgPhyOffset(8byte,消息在文件中的起始位置)+msgSize(4byte,消息在文件中占用的长度)+msgTagCode(8byte,消息的tag的Hash值)。 在ConsumeQueue.java当中有一个常量CQ_STORE_UNIT_SIZE=20,这个常量就表示一个数据块的大小。
3、IndexFile文件主要是辅助消息检索。消费者进行消息消费时,通过ConsumeQueue文件就足够完成消息检索了,但是如果要按照MeessageId或者MessageKey来检索文件,比如RocketMQ管理控制台的消息轨迹功能,ConsumeQueue文件就不够用了。IndexFile文件就是用来辅助这类消息检索的。他的文件名比较特殊,不是以消息偏移量命名,而是用的时间命名。但是其实,他也是一个固定大小的文件。 **文件结构:**他的文件结构由 indexHeader(固定40byte)+ slot(固定500W个,每个固定20byte) + index(最多500W*4个,每个固定20byte) 三个部分组成。indexFile的详细结构有大厂之前面试过,可以参考一下我的博文: https://blog.csdn.net/roykingw/article/details/120086520 这些文件的结构可以尝试到源码当中去验证。这里重点思考为什么这样设计,以及这样设计如何支撑上层的功能。
整体的消息存储结构如下图:
二、消息持久化 -- 重点
消息既然要持久化,就必须有对应的删除机制。RocketMQ内置了一套过期文件的删除机制。首先:如何判断过期文件: RocketMQ中,CommitLog文件和ConsumeQueue文件都是以偏移量命名,对于非当前写的文件,如果超过了一定的保留时间,那么这些文件都会被认为是过期文件,随时可以删除。这个保留时间就是在broker.conf中配置的fileReservedTime属性。 注意,RocketMQ判断文件是否过期的唯一标准就是非当前写文件的保留时间,而并不关心文件当中的消息是否被消费过。所以,RocketMQ的消息堆积也是有时间限度的。
然后:何时删除过期文件: RocketMQ内部有一个定时任务,对文件进行扫描,并且触发文件删除的操作。用户可以指定文件删除操作的执行时间。在broker.conf中deleteWhen属性指定。默认是凌晨四点。 另外,RocketMQ还会检查服务器的磁盘空间是否足够,如果磁盘空间的使用率达到一定的阈值,也会触发过期文件删除。所以RocketMQ官方就特别建议,broker的磁盘空间不要少于4G。
三、过期文件删除
之后,操作系统为了避免CPU完全被各种IO调用给占用,引入了DMA(直接存储器存储)。由DMA来负责这些频繁的IO操作。DMA是一套独立的指令集,不会占用CPU的计算资源。这样,CPU就不需要参与具体的数据复制的工作,只需要管理DMA的权限即可。
DMA拷贝极大的释放了CPU的性能,因此他的拷贝速度会比CPU拷贝要快很多。但是,其实DMA拷贝本身,也在不断优化。 引入DMA拷贝之后,在读写请求的过程中,CPU不再需要参与具体的工作,DMA可以独立完成数据在系统内部的复制。但是,数据复制过程中,依然需要借助数据总进线。当系统内的IO操作过多时,还是会占用过多的数据总线,造成总线冲突,最终还是会影响数据读写性能。
为了避免DMA总线冲突对性能的影响,后来又引入了Channel通道的方式。Channel,是一个完全独立的处理器,专门负责IO操作。既然是处理器,Channel就有自己的IO指令,与CPU无关,他也更适合大型的IO操作,性能更高。image 这也解释了,为什么Java应用层与零拷贝相关的操作都是通过Channel的子类实现的。这其实是借鉴了操作系统中的概念。 而所谓的零拷贝技术,其实并不是不拷贝,而是要尽量减少CPU拷贝。
1:理解CPU拷贝和DMA拷贝 我们知道,操作系统对于内存空间,是分为用户态和内核态的。用户态的应用程序无法直接操作硬件,需要通过内核空间进行操作转换,才能真正操作硬件。这其实是为了保护操作系统的安全。正因为如此,应用程序需要与网卡、磁盘等硬件进行数据交互时,就需要在用户态和内核态之间来回的复制数据。而这些操作,原本都是需要由CPU来进行任务的分配、调度等管理步骤的,早先这些IO接口都是由CPU独立负责,所以当发生大规模的数据读写操作时,CPU的占用率会非常高。
以一次文件的读写操作为例,应用程序对磁盘文件的读与写,都需要经过内核态与用户态之间的状态切换,每次状态切换的过程中,就需要有大量的数据复制。image 在这个过程中,总共需要进行四次数据拷贝。而磁盘与内核态之间的数据拷贝,在操作系统层面已经由CPU拷贝优化成了DMA拷贝。而内核态与用户态之间的拷贝依然是CPU拷贝。所以,在这个场景下,零拷贝技术优化的重点,就是内核态与用户态之间的这两次拷贝。
而mmap文件映射的方式,就是在用户态不再保存文件的内容,而只保存文件的映射,包括文件的内存起始地址,文件大小等。真实的数据,也不需要在用户态留存,可以直接通过操作映射,在内核态完成数据复制。image 这个拷贝过程都是在操作系统的系统调用层面完成的,在Java应用层,其实是无法直接观测到的,但是我们可以去JDK源码当中进行间接验证。在JDK的NIO包中,java.nio.HeapByteBuffer映射的就是JVM的一块堆内内存,在HeapByteBuffer中,会由一个byte数组来缓存数据内容,所有的读写操作也是先操作这个byte数组。这其实就是没有使用零拷贝的普通文件读写机制。
例如,我们可以在Linux机器上,运行一下下面这个最简单不过的应用程序:import java.util.Scanner;public class BlockDemo { public static void main(String[] args) { Scanner scanner = new Scanner(System.in); final String s = scanner.nextLine(); System.out.println(s); }} 通过Java指令运行起来后,可以用jps查看到运行的进程ID。然后,就可以使用lsof -p {PID}的方式查看文件的映射情况。
这里面看到的mem类型的FD其实就是文件映射。cwd 表示程序的工作目录。rtd 表示用户的根目录。 txt表示运行程序的指令。下面的1u表示Java应用的标准输出,2u表示Java应用的标准错误输出,默认的/dev/pts/1是linux当中的伪终端。通常服务器上会写 java xxx 1>text.txt 2>&1 这样的脚本,就是指定这里的1u,2u。 最后,这种mmap的映射机制由于还是需要用户态保存文件的映射信息,数据复制的过程也需要用户态的参与,这其中的变数还是非常多的。所以,mmap机制适合操作小文件,如果文件太大,映射信息也会过大,容易造成很多问题。通常mmap机制建议的映射文件大小不要超过2G 。而RocketMQ做大的CommitLog文件保持在1G固定大小,也是为了方便文件映射。
2:再来理解下mmap文件映射机制是怎么回事。 mmap机制的具体实现参见配套示例代码。主要是通过java.nio.channels.FileChannel的map方法完成映射。
百度去搜索一下零拷贝,铺天盖地的也都是拿这个场景在举例。image 早期的sendfile实现机制其实还是依靠CPU进行页缓存与socket缓存区之间的数据拷贝。但是,在后期的不断改进过程中,sendfile优化了实现机制,在拷贝过程中,并不直接拷贝文件的内容,而是只拷贝一个带有文件位置和长度等信息的文件描述符FD,这样就大大减少了需要传递的数据。而真实的数据内容,会交由DMA控制器,从页缓存中打包异步发送到socket中。
为什么大家都喜欢用这个场景来举例呢?其实我们去看下Linux操作系统的man帮助手册就能看到一部分答案。使用指令man 2 sendfile就能看到Linux操作系统对于sendfile这个系统调用的手册。
2.6.33版本以前的Linux内核中,out_fd只能是一个socket,所以网上铺天盖地的老资料都是拿网卡来举例。但是现在版本已经没有了这个限制。 最后,sendfile机制在内核态直接完成了数据的复制,不需要用户态的参与,所以这种机制的传输效率是非常稳定的。sendfile机制非常适合大数据的复制转移。
3:梳理下sendFile机制是怎么运行的。
4.1 零拷贝技术加速文件读写 零拷贝(zero-copy)是操作系统层面提供的一种加速文件读写的操作机制,非常多的开源软件都在大量使用零拷贝,来提升IO操作的性能。对于Java应用层,对应着mmap和sendFile两种方式。接下来,咱们深入操作系统来详细理解一下零拷贝。
通常应用程序往磁盘写文件时,由于磁盘空间不是连续的,会有很多碎片。所以我们去写一个文件时,也就无法把一个文件写在一块连续的磁盘空间中,而需要在磁盘多个扇区之间进行大量的随机写。这个过程中有大量的寻址操作,会严重影响写数据的性能。而顺序写机制是在磁盘中提前申请一块连续的磁盘空间,每次写数据时,就可以避免这些寻址操作,直接在之前写入的地址后面接着写就行。 Kafka官方详细分析过顺序写的性能提升问题。Kafka官方曾说明,顺序写的性能基本能够达到内存级别。而如果配备固态硬盘,顺序写的性能甚至有可能超过写内存。而RocketMQ很大程度上借鉴了Kafka的这种思想。 例如可以看下org.apache.rocketmq.store.CommitLog#DefaultAppendMessageCallback中的doAppend方法。在这个方法中,会以追加的方式将消息先写入到一个堆外内存byteBuffer中,然后再通过fileChannel写入到磁盘。
4.2 顺序写加速文件写入磁盘
[root@192-168-65-174 ~]# cat /proc/meminfo MemTotal: 16266172 kB.....Cached: 923724 kB.....Dirty: 32 kBWriteback: 0 kB.....Mapped: 133032 kB.....
在操作系统层面,当应用程序写入一个文件时,文件内容并不会直接写入到硬件当中,而是会先写入到操作系统中的一个缓存PageCache中。PageCache缓存以4K大小为单位,缓存文件的具体内容。这些写入到PageCache中的文件,在应用程序看来,是已经完全落盘保存好了的,可以正常修改、复制等等。但是,本质上,PageCache依然是内存状态,所以一断电就会丢失。因此,需要将内存状态的数据写入到磁盘当中,这样数据才能真正完成持久化,断电也不会丢失。这个过程就称为刷盘。 PageCache是源源不断产生的,而Linux操作系统显然不可能时时刻刻往硬盘写文件。所以,操作系统只会在某些特定的时刻将PageCache写入到磁盘。例如当我们正常关机时,就会完成PageCache刷盘。另外,在Linux中,对于有数据修改的PageCache,会标记为Dirty(脏页)状态。当Dirty Page的比例达到一定的阈值时,就会触发一次刷盘操作。例如在Linux操作系统中,可以通过/proc/meminfo文件查看到Page Cache的状态。
但是,只要操作系统的刷盘操作不是时时刻刻执行的,那么对于用户态的应用程序来说,那就避免不了非正常宕机时的数据丢失问题。因此,操作系统也提供了一个系统调用,应用程序可以自行调用这个系统调用,完成PageCache的强制刷盘。在Linux中是fsync,同样我们可以用man 2 fsync 指令查看。
同步刷盘:在返回写成功状态时,消息已经被写入磁盘。具体流程是,消息写入内存的PAGECACHE后,立刻通知刷盘线程刷盘, 然后等待刷盘完成,刷盘线程执行完成后唤醒等待的线程,返回消息写 成功的状态。异步刷盘:在返回写成功状态时,消息可能只是被写入了内存的PAGECACHE,写操作的返回快,吞吐量大;当内存里的消息量积累到一定程度时,统一触发写磁盘动作,快速写入。配置方式:刷盘方式是通过Broker配置文件里的flushDiskType 参数设置的,这个参数被配置成SYNC_FLUSH、ASYNC_FLUSH中的 一个。 同步刷盘机制会更频繁的调用fsync,所以吞吐量相比异步刷盘会降低,但是数据的安全性会得到提高。
RocketMQ对于何时进行刷盘,也设计了两种刷盘机制,同步刷盘和异步刷盘。
4.3 刷盘机制保证消息不丢失
四、高效文件写
如果Broker以一个集群的方式部署,会有一个master节点和多个slave节点,消息需要从Master复制到Slave上。而消息复制的方式分为同步复制和异步复制。同步复制:同步复制是等Master和Slave都写入消息成功后才反馈给客户端写入成功的状态。在同步复制下,如果Master节点故障,Slave上有全部的数据备份,这样容易恢复数据。但是同步复制会增大数据写入的延迟,降低系统的吞吐量。异步复制:异步复制是只要master写入消息成功,就反馈给客户端写入成功的状态。然后再异步的将消息复制给Slave节点。在异步复制下,系统拥有较低的延迟和较高的吞吐量。但是如果master节点故障,而有些数据没有完成复制,就会造成数据丢失。配置方式:消息复制方式是通过Broker配置文件里的brokerRole参数进行设置的,这个参数可以被设置成ASYNC_MASTER、 SYNC_MASTER、SLAVE三个值中的一个。
五、 消息主从复制
Producer发送消息时,默认会轮询目标Topic下的所有MessageQueue,并采用递增取模的方式往不同的MessageQueue上发送消息,以达到让消息平均落在不同的queue上的目的。而由于MessageQueue是分布在不同的Broker上的,所以消息也会发送到不同的broker上。image 同时生产者在发送消息时,可以指定一个MessageQueueSelector。通过这个对象来将消息发送到自己指定的MessageQueue上。这样可以保证消息局部有序。
6.1 Producer负载均衡
而每当实例的数量有变更,都会触发一次所有实例的负载均衡,这时候会按照queue的数量和实例的数量平均分配queue给每个实例。每次分配时,都会将MessageQueue和消费者ID进行排序后,再用不同的分配算法进行分配。内置的分配的算法共有六种,分别对应AllocateMessageQueueStrategy下的六种实现类,可以在consumer中直接set来指定。默认情况下使用的是最简单的平均分配策略。
AllocateMachineRoomNearby: 将同机房的Consumer和Broker优先分配在一起。 这个策略可以通过一个machineRoomResolver对象来定制Consumer和Broker的机房解析规则。然后还需要引入另外一个分配策略来对同机房的Broker和Consumer进行分配。一般也就用简单的平均分配策略或者轮询分配策略。感觉这东西挺鸡肋的,直接给个属性指定机房不是挺好的吗。 源码中有测试代码AllocateMachineRoomNearByTest。 在示例中:Broker的机房指定方式: messageQueue.getBrokerName().split(\"-\")[0],而Consumer的机房指定方式:clientID.split(\"-\")[0] clinetID的构建方式:见ClientConfig.buildMQClientId方法。按他的测试代码应该是要把clientIP指定为IDC1-CID-0这样的形式。
AllocateMessageQueueAveragely:平均分配。将所有MessageQueue平均分给每一个消费者AllocateMessageQueueAveragelyByCircle: 轮询分配。轮流的给一个消费者分配一个MessageQueue。AllocateMessageQueueByConfig: 不分配,直接指定一个messageQueue列表。类似于广播模式,直接指定所有队列。AllocateMessageQueueByMachineRoom:按逻辑机房的概念进行分配。又是对BrokerName和ConsumerIdc有定制化的配置。AllocateMessageQueueConsistentHash。源码中有测试代码AllocateMessageQueueConsitentHashTest。这个一致性哈希策略只需要指定一个虚拟节点数,是用的一个哈希环的算法,虚拟节点是为了让Hash数据在换上分布更为均匀。
例如平均分配时的分配情况是这样的:
1、集群模式在集群消费模式下,每条消息只需要投递到订阅这个topic的Consumer Group下的一个实例即可。RocketMQ采用主动拉取的方式拉取并消费消息,在拉取的时候需要明确指定拉取哪一条message queue。
2、广播模式 广播模式下,每一条消息都会投递给订阅了Topic的所有消费者实例,所以也就没有消息分配这一说。而在实现上,就是在Consumer分配Queue时,所有Consumer都分到所有的Queue。 广播模式实现的关键是将消费者的消费偏移量不再保存到broker当中,而是保存到客户端当中,由客户端自行维护自己的消费偏移量。
6.2 Consumer负载均衡 Consumer也是以MessageQueue为单位来进行负载均衡。分为集群模式和广播模式。
六、负载均衡 --重点
集群消费方式下,消息消费失败后期望消息重试,需要在消息监听器接口的实现中明确进行配置。可以有三种配置方式:返回Action.ReconsumeLater-推荐返回null抛出异常
7.1、如何让消息进行重试
重试的消息会进入一个 “%RETRY%”+ConsumeGroup 的队列中。
然后RocketMQ默认允许每条消息最多重试16次,每次重试的间隔时间如下:重试次数 与上次重试的间隔时间 重试次数 与上次重试的间隔时间1 10 秒 9 7 分钟2 30 秒 10 8 分钟3 1 分钟 11 9 分钟4 2 分钟 12 10 分钟5 3 分钟 13 20 分钟6 4 分钟 14 30 分钟7 5 分钟 15 1 小时8 6 分钟 16 2 小时这个重试时间跟延迟消息的延迟级别是对应的。不过取的是延迟级别的后16级别。messageDelayLevel=1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h这个重试时间可以将源码中的org.apache.rocketmq.example.quickstart.Consumer里的消息监听器返回状态改为RECONSUME_LATER测试一下。
重试次数:如果消息重试16次后仍然失败,消息将不再投递。转为进入死信队列。另外一条消息无论重试多少次,这些重试消息的MessageId始终都是一样的。然后关于这个重试次数,RocketMQ可以进行定制。例如通过consumer.setMaxReconsumeTimes(20);将重试次数设定为20次。当定制的重试次数超过16次后,消息的重试时间间隔均为2小时。
关于MessageId:在老版本的RocketMQ中,一条消息无论重试多少次,这些重试消息的MessageId始终都是一样的。但是在4.9.1版本中,每次重试MessageId都会重建。
配置覆盖:消息最大重试次数的设置对相同GroupID下的所有Consumer实例有效。并且最后启动的Consumer会覆盖之前启动的Consumer的配置。
7.2、重试消息如何处理
七、消息重试
当一条消息消费失败,RocketMQ就会自动进行消息重试。而如果消息超过最大重试次数,RocketMQ就会认为这个消息有问题。但是此时,RocketMQ不会立刻将这个有问题的消息丢弃,而会将其发送到这个消费者组对应的一种特殊队列:死信队列。RocketMQ默认的重试次数是16次。见源码org.apache.rocketmq.common.subscription.SubscriptionGroupConfig中的retryMaxTimes属性。这个重试次数可以在消费者端进行配置。 例如 DefaultMQPushConsumer实例中有个setMaxReconsumeTimes方法指定重试次数。
死信队列的名称是%DLQ%+ConsumGroup
死信队列的特征:一个死信队列对应一个ConsumGroup,而不是对应某个消费者实例。如果一个ConsumeGroup没有产生死信队列,RocketMQ就不会为其创建相应的死信队列。一个死信队列包含了这个ConsumeGroup里的所有死信消息,而不区分该消息属于哪个Topic。死信队列中的消息不会再被消费者正常消费。死信队列的有效期跟正常消息相同。默认3天,对应broker.conf中的fileReservedTime属性。超过这个最长时间的消息都会被删除,而不管消息是否消费过。
八、死信队列
在MQ系统中,对于消息幂等有三种实现语义:at most once 最多一次:每条消息最多只会被消费一次at least once 至少一次:每条消息至少会被消费一次exactly once 刚刚好一次:每条消息都只会确定的消费一次这三种语义都有他适用的业务场景。
9.1、幂等的概念
在互联网应用中,尤其在网络不稳定的情况下,消息队列 RocketMQ 的消息有可能会出现重复,这个重复简单可以概括为以下情况:发送时消息重复当一条消息已被成功发送到服务端并完成持久化,此时出现了网络闪断或者客户端宕机,导致服务端对客户端应答失败。 如果此时生产者意识到消息发送失败并尝试再次发送消息,消费者后续会收到两条内容相同并且 Message ID 也相同的消息。投递时消息重复消息消费的场景下,消息已投递到消费者并完成业务处理,当客户端给服务端反馈应答的时候网络闪断。 为了保证消息至少被消费一次,消息队列 RocketMQ 的服务端将在网络恢复后再次尝试投递之前已被处理过的消息,消费者后续会收到两条内容相同并且 Message ID 也相同的消息。负载均衡时消息重复(包括但不限于网络抖动、Broker 重启以及订阅方应用重启)当消息队列 RocketMQ 的 Broker 或客户端重启、扩容或缩容时,会触发 Rebalance,此时消费者可能会收到重复消息。
9.2、消息幂等的必要性
9.3、处理方式 从上面的分析中,我们知道,在RocketMQ中,是无法保证每个消息只被投递一次的,所以要在业务上自行来保证消息消费的幂等性。 而要处理这个问题,RocketMQ的每条消息都有一个唯一的MessageId,这个参数在多次投递的过程中是不会改变的,所以业务上可以用这个MessageId来作为判断幂等的关键依据。 但是,这个MessageId是无法保证全局唯一的,也会有冲突的情况。所以在一些对幂等性要求严格的场景,最好是使用业务上唯一的一个标识比较靠谱。例如订单ID。而这个业务标识可以使用Message的Key来进行传递。
九、消息幂等
Dledger是RocketMQ自4.5版本引入的实现高可用集群的一项技术。他基于Raft算法进行构建,在RocketMQ的主从集群基础上,增加了自动选举的功能。当master节点挂了之后,会在集群内自动选举出一个新的master节点。虽然Dledger机制目前还在不断验证改进的阶段,但是作为基础的Raft算法,已经是目前互联网行业非常认可的一种高可用算法了。Kafka目前也在基于Raft算法,构建摆脱Zookeeper的集群化方案。
RocketMQ中的Dledger集群主要包含两个功能:1、从集群中选举产生master节点。2、优化master节点往slave节点的消息同步机制。
首先:每个节点有三个状态,Leader,follower和candidate(候选人)。正常运行的情况下,集群中会有一个leader,其他都是follower,follower只响应Leader和Candidate的请求,而客户端的请求全部由Leader处理,即使有客户端请求到了一个follower,也会将请求转发到leader。 集群刚启动时,每个节点都是follower状态,之后集群内部会发送一个timeout信号,所有follower就转成candidate去拉取选票,获得大多数选票的节点选为leader,其他候选人转为follower。如果一个timeout信号发出时,没有选出leader,将会重新开始一次新的选举。而Leader节点会往其他节点发送心跳信号,确认他的leader状态。然后会启动定时器,如果在指定时间内没有收到Leader的心跳,就会转为Candidate状态,然后向其他成员发起投票请求,如果收到半数以上成员的投票,则Candidate会晋升为Leader。然后leader也有可能会退化成follower。
然后,在Raft协议中,会将时间分为一些任意时间长度的时间片段,叫做term。term会使用一个全局唯一,连续递增的编号作为标识,也就是起到了一个逻辑时钟的作用。image 在每一个term时间片里,都会进行新的选举,每一个Candidate都会努力争取成为leader。获得票数最多的节点就会被选举为Leader。被选为Leader的这个节点,在一个term时间片里就会保持leader状态。这样,就会保证在同一时间段内,集群中只会有一个Leader。在某些情况下,选票可能会被各个节点瓜分,形成不了多数派,那这个term可能直到结束都没有leader,直到下一个term再重新发起选举,这也就没有了Zookeeper中的脑裂问题。而在每次重新选举的过程中, leader也有可能会退化成为follower。也就是说,在这个集群中, leader节点是会不断变化的。
然后,每次选举的过程中,每个节点都会存储当前term编号,并在节点之间进行交流时,都会带上自己的term编号。如果一个节点发现他的编号比另外一个小,那么他就会将自己的编号更新为较大的那一个。而如果leader或者candidate发现自己的编号不是最新的,他就会自动转成follower。如果接收到的请求term编号小于自己的编号,term将会拒绝执行。 在选举过程中,Raft协议会通过心跳机制发起leader选举。节点都是从follower状态开始的,如果收到了来自leader或者candidate的心跳RPC请求,那他就会保持follower状态,避免争抢成为candidate。而leader会往其他节点发送心跳信号,来确认自己的地位。如果follower一段时间(两个timeout信号)内没有收到Leader的心跳信号,他就会认为leader挂了,发起新一轮选举。
所以以三个节点的集群为例,选举过程会是这样的: 集群启动时,三个节点都是follower,发起投票后,三个节点都会给自己投票。这样一轮投票下来,三个节点的term都是1,是一样的,这样是选举不出Leader的。 当一轮投票选举不出Leader后,三个节点会进入随机休眠,例如A休眠1秒,B休眠3秒,C休眠2秒。 一秒后,A节点醒来,会把自己的term加一票,投为2。然后2秒时,C节点醒来,发现A的term已经是2,比自己的1大,就会承认A是Leader,把自己的term也更新为2。实际上这个时候,A已经获得了集群中的多数票,2票,A就会被选举成Leader。这样,一般经过很短的几轮选举,就会选举出一个Leader来。 到3秒时,B节点会醒来,他也同样会承认A的term最大,他是Leader,自己的term也会更新为2。这样集群中的所有Candidate就都确定成了leader和follower. 然后在一个任期内,A会不断发心跳给另外两个节点。当A挂了后,另外的节点没有收到A的心跳,就会都转化成Candidate状态,重新发起选举。
先来看第一个功能:Dledger是使用Raft算法来进行节点选举的。
使用Dledger集群后,数据主从同步会分为两个阶段,一个是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协议完成了两阶段的数据同步。
然后,Dledger还会采用Raft协议进行多副本的消息同步
十、详解Dledger集群 -- 了解
RocketMq原理
1、关注重点从之前的介绍中,我们已经了解到,在RocketMQ中,实际进行消息存储、推送等核心功能的是Broker。那NameServer具体做什么用呢?NameServer的核心作用其实就只有两个一是维护Broker的服务地址并进行及时的更新。二是给Producer和Consumer提供服务获取Broker列表。 NameServer的启动入口为NamesrvStartup类的main方法,我们可以进行逐步调试。这次看源码,我们不要太过陷入其中的细节,我们的目的是先搞清楚NameServer的大体架构。
整个NameServer的核心就是一个NamesrvController对象。这个controller对象就跟java Web开发中的Controller功能类似,都是响应客户端请求的。 在Controller的启动以及关闭过程中,会逐步启动RocketMQ的各种内部服务。要注意对这些关键服务的梳理。
从启动和关闭这两个关键步骤,我们可以总结出NameServer的组件其实并不是很多,整个NameServer的结构是这样:
2、源码重点
2.1、NameServer的启动过程
1、关注重点 Broker是整个RocketMQ的业务核心,所有消息存储、转发这些最为重要的业务都是在Broker中进行处理的。 Broker的内部架构,有点类似于JavaWeb开发的MVC架构。有Controller负责响应请求,各种Service组件负责具体业务,然后还有负责消息存盘的功能模块则类似于Dao。 第一轮看源码,重点依然是,是通过Broker的启动过程,观察总结出Broker的内部服务。
**首先:**在BrokerStartup.createBrokerController方法中可以看到Broker的几个核心配置:BrokerConfigNettyServerConfig :Netty服务端占用了10911端口。同样也可以在配置文件中覆盖。NettyClientConfigMessageStoreConfig
**然后:**在BrokerController.start方法可以看到启动了一大堆Broker的核心服务,我们挑一些重要的this.messageStore.start();启动核心的消息存储组件this.remotingServer.start();this.fastRemotingServer.start(); 启动两个Netty服务this.brokerOuterAPI.start();启动客户端,往外发请求BrokerController.this.registerBrokerAll: 向NameServer注册心跳。this.brokerStatsManager.start();this.brokerFastFailure.start();这也是一些负责具体业务的功能组件我们现在不需要了解这些核心组件的具体功能,只要有个大概,Broker中有一大堆的功能组件负责具体的业务。后面等到分析具体业务时再去深入每个服务的细节。
我们需要抽象出Broker的一个整体结构:
Broker启动的入口在BrokerStartup这个类,可以从他的main方法开始调试。启动过程关键点:重点也是围绕一个BrokerController对象,先创建,然后再启动。
2.2、Broker的启动过程
服务启动过程
1、功能回顾 网络通信服务是构建分布式应用的基础,也是我们去理解RocketMQ底层业务的基础。 RocketMQ使用Netty框架提供了一套基于服务码的服务注册机制,让各种不同的组件都可以按照自己的需求,注册自己的服务方法。RocketMQ的这一套服务注册机制,是非常简洁使用的。在使用Netty进行其他相关应用开发时,都可以借鉴他的这一套服务注册机制。例如要开发一个大型的IM项目,要加减好友、发送文本,图片,甚至红包、维护群聊信息等等各种各样的请求,这些请求如何封装,就可以很好的参考这个框架。 Netty的所有远程通信功能都由remoting模块实现。RemotingServer模块里包含了RPC的服务端RemotingServer以及客户端RemotingClient。在RocketMQ中,涉及到的远程服务非常多,在RocketMQ中,NameServer主要是RPC的服务端RemotingServer,Broker对于客户端来说,是RPC的服务端RemotingServer,而对于NameServer来说,又是RPC的客户端。各种Client是RPC的客户端RemotingClient。 需要理解的是,RocketMQ基于Netty保持客户端与服务端的长连接Channel。只要Channel是稳定的,那么即可以从客户端发请求到服务端,同样服务端也可以发请求到客户端。例如在事务消息场景中,就需要Broker多次主动向Producer发送请求确认事务的状态。所以,RemotingServer和RemotingClient都需要注册自己的服务。
4、理解服务注册流程整体服务加载流程如下图:深度解析:尝试去理解一下RemotingCommand的序列化协议。 NettyEncoder和NettyDecoder。RocketMQ的序列化协议还是比较复杂的,你可以尝试简化一下序列化方法,比如使用JSON字符串来做序列化。
2、源码重点:
3.1、Netty服务注册框架
1、关注重点 在之前我们已经介绍到了。Broker会在启动时向NameServer注册自己的服务信息,并且会定时的往NameServer发送心跳信息。而NameServer会维护Broker的路由列表,并对路由列表进行实时更新。这一轮就重点梳理这个问题。
BrokerController.this.registerBrokerAll这个方法就是注册心跳的入口。image然后,在NameServer中也会启动一个定时任务,扫描不活动的Broker。具体观察NamesrvController.initialize方法
2、源码重点 BrokerController.this.registerBrokerAll方法会发起向NameServer注册心跳。启动时会立即注册,同时也会启动一个线程池,以10秒延迟,默认30秒的间隔 持续向NameServer发送心跳。
3.2、Broker心跳注册过程
Producer有两种:一种是普通发送者:DefaultMQProducer。只负责发送消息,发送完消息,就可以停止了。另一种是事务消息发送者: TransactionMQProducer。支持事务消息机制。需要在事务消息过程中提供事务状态确认的服务,这就要求事务消息发送者虽然是一个客户端,但是也要完成整个事务消息的确认机制后才能退出。
整个Producer的流程,大致分两个步骤start方法,进行一大堆的准备工作.各种各样的send方法,进行消息发送。
1、 我们关注下Producer的核心启动流程以及两种消息发送者的区别。 在mQClientFactory的start方法中,启动了生产者的一大堆重要服务。 然后在DefaultMQProducerImpl的start方法中,又回到了生产者的mqClientFactory的启动过程,这中间有服务状态的管理。这里RocketMQ的所有客户端实例,包括生产者和消费者,都是统一交由mQClientFactory组件来启动,也就是说,所有客户端的启动流程是固定的,不同客户端的区别只是在于他们在启动前注册的一些信息不同。例如生产者注册到producerTable,消费者注册到consumerTable,管理控制端注册到adminExtTable
**2、**Producer如何管理Borker路由信息: Producer需要拉取Broker列表,然后跟Broker建立连接等等很多核心的流程,其实都是在发送消息时建立的。因为在启动时,还不知道要拉取哪个Topic的Broker列表呢。所以对于这个问题,我们关注的重点,不应该是start方法,而是send方法。3、 关于Producer的负载均衡。在之前介绍RocketMQ的顺序消息时,讲到了Producer的负载均衡策略,默认会把消息平均的发送到所有MessageQueue里的。那到底是怎么进行负载均衡的呢?4、 在发送Netty请求时,如何制定Broker?实际上是指定的MessageQueue,而不是Topic。Topic只是用来找MessageQueue。
那我们重点关注以下几个问题:
1、关注重点首先回顾下我们之前的Producer使用案例。
所有Producer的启动过程,最终都会调用DefaultMQProducerImpl#start方法。在start方法中的通过一个mQClientFactory对象,启动生产者的一大堆重要服务。 这里其实就是一种设计模式,虽然有很多种不同的客户端,但是这些客户端的启动流程最终都是统一的,全是交由mQClientFactory对象来启动。而不同之处在于这些客户端在启动过程中,按照服务端的要求注册不同的信息。例如生产者注册到producerTable,消费者注册到consumerTable,管理控制端注册到adminExtTable
然后关于两种消息发送者:DefaultMQProducer只需要构建一个Netty客户端,往Broker发送消息就行了。注意,异步回调只是在Producer接收到Broker的响应后自行调整流程,不需要提供Netty服务。TransactionMQProducer由于需要在事务消息机制中给Broker提供状态确认服务,所以在发送消息的同时,还需要保持连接,提供服务。在TransactionMQProducer的启动过程中,会往RemotingClient中注册相应的Processor,这样RemotingServer和RemotingClient之间就可以通过channel进行双向的服务请求了。
1、 Producer的核心启动流程以及两种消息发送者的区别。
Producer需要拉取Broker列表,然后跟Broker建立连接等等很多核心的流程,其实都是在发送消息时建立的。因为在启动时,还不知道要拉取哪个Topic的Broker列表呢。所以对于这个问题,我们关注的重点,不应该是start方法,而是send方法。而对NameServer的地址管理,则是散布在启动和发送的多个过程当中,并且NameServer地址可以通过一个Http服务来获取。Send方法中,首先需要获得Topic的路由信息。这会从本地缓存中获取,如果本地缓存中没有,就从NameServer中去申请。 核心在 org.apache.rocketmq.client.impl.producer.DefaultMQProducerImpl#tryToFindTopicPublishInfo方法
路由信息大致的管理流程:
**2、**Producer如何管理Borker路由信息?
Producer在获取路由信息后,会选出一个MessageQueue去发送消息。这个选MessageQueue的方法就是一个索引自增然后取模的方式。image然后根据MessageQueue再找所在的Broker,往Broker发送请求。
3、 关于Producer的负载均衡。
3.3、Producer发送消息过程
1、关注重点结合我们之前的示例,回顾下消费者这一块的几个重点问题:消费者也是有两种,推模式消费者和拉模式消费者。优秀的MQ产品都会有一个高级的目标,就是要提升整个消息处理的性能。而要提升性能,服务端的优化手段往往不够直接,最为直接的优化手段就是对消费者进行优化。所以在RocketMQ中,整个消费者的业务逻辑是非常复杂的,甚至某种程度上来说,比服务端更复杂,所以,在这里我们重点关注用得最多的推模式的消费者。消费者组之间有集群模式和广播模式两种消费模式。我们就要了解下这两种集群模式是如何做的逻辑封装。然后我们关注下消费者端的负载均衡的原理。即消费者是如何绑定消费队列的,哪些消费策略到底是如何落地的。最后我们来关注下在推模式的消费者中,MessageListenerConcurrently 和MessageListenerOrderly这两种消息监听器的处理逻辑到底有什么不同,为什么后者能保持消息顺序。我们接下来就通过这几个问题来把RocketMQ的消费者部分源码串起来。
1、启动 DefaultMQPushConsumer.start作为入口。最终消费者的启动过程,跟生产者一样,也交由了mQClientFactory。 通过mQClientFactory,消费者实例也启动了一大堆的服务。这些服务可以结合具体场景再进行深入。例如pullMessageService主要处理拉取消息服务,rebalanceService主要处理客户端的负载均衡。
2、消息拉取: 拉模式核心服务类: PullMessageServicePullRequest里有messageQueue和processQueue,其中messageQueue负责拉取消息,拉取到后,将消息存入processQueue,进行处理。 存入后就可以清空messageQueue,继续拉取了。
4 并发消费与顺序消费的过程消费的过程依然是在DefaultMQPushConsumerImpl的 consumeMessageService中。他有两个子类ConsumeMessageConcurrentlyService和ConsumeMessageOrderlyService。其中最主要的差别是ConsumeMessageOrderlyService会在消费前把队列锁起来,优先保证拉取同一个队列里的消息。消费过程的入口在DefaultMQPushConsumerImpl的pullMessage中定义的PullCallback中。
3.4、Consumer拉取消息过程
3、 消费者部分小结:RocketMQ消息消费方式分别为集群模式、广播模式。消息队列负载由RebalanceService线程默认每隔20s进行一次消息队列负载,根据当前消费者组内消费者个数与主题队列数量按照某一种负载算法进行队列分配,分配原则为同一个消费者可以分配多个消息消费队列,同一个消息消费队列同一个时间只会分配给一个消费者。消息拉取由PullMessageService线程根据RebalanceService线程创建的拉取任务进行拉取,默认每次拉取一批消息(可以由业务指定,默认是1),提交给消费者消费线程后继续下一次消息拉取。如果消息消费过慢产生消息堆积会触发消息消费拉取流控。并发消息消费指消费线程池中的线程可以并发对同一个消息队列的消息进行消费,消费成功后,取出消息队列中最小的消息偏移量作为消息消费进度偏移量存储在于消息消费进度存储文件中,集群模式消息消费进度存储在Broker(消息服务器),广播模式消息消费进度存储在消费者端。RocketMQ不支持任意精度的定时调度消息,只支持自定义的消息延迟级别,例如1s、2s、5s等,可通过在broker配置文件中设置messageDelayLevel。顺序消息一般使用集群模式,是指对消息消费者内的线程池中的线程对消息消费队列只能串行消费。与并发消息消费最本质的区别是消息消费时必须成功锁定消息消费队列,在Broker端会存储消息消费队列的锁占用情况。
三、客户端主要业务
1、关注重点我们接着上面的流程,Producer把消息发到了Broker,接下来就关注下Broker接收到消息后是如何把消息进行存储的。最终存储的文件有哪些?commitLog:消息存储目录config:运行期间一些配置信息consumerqueue:消息消费队列存储目录index:消息索引文件存储目录abort:如果存在改文件寿命Broker非正常关闭checkpoint:文件检查点,存储CommitLog文件最后一次刷盘时间戳、consumerquueue最后一次刷盘时间,index索引文件最后一次刷盘时间戳。还记得我们之前看到的Broker的核心组件吗?其中messageStore就是负责消息存储的核心组件。
1-commitLog写入CommitLog的doAppend方法就是Broker写入消息的实际入口。这个方法最终会把消息追加到MappedFile映射的一块内存里,并没有直接写入磁盘。写入消息的过程是串行的,一次只会允许一个线程写入。
2-分发ConsumeQueue和IndexFile 当CommitLog写入一条消息后,在DefaultMessageStore的start方法中,会启动一个后台线程reputMessageService每隔1毫秒就会去拉取CommitLog中最新更新的一批消息,然后分别转发到ComsumeQueue和IndexFile里去,这就是他底层的实现逻辑。 并且,如果服务异常宕机,会造成CommitLog和ConsumeQueue、IndexFile文件不一致,有消息写入CommitLog后,没有分发到索引文件,这样消息就丢失了。DefaultMappedStore的load方法提供了恢复索引文件的方法,入口在load方法。
这里涉及到了对于同步刷盘与异步刷盘的不同处理机制。这里有很多极致提高性能的设计,对于我们理解和设计高并发应用场景有非常大的借鉴意义。 同步刷盘也是使用异步机制实现的。刷盘是一个很重的操作,所以,RocketMQ即便是同步刷盘,也要对刷盘次数精打细算。对于单条消息,那么直接将commitlog刷盘即可。但是对于批量消息,RockeMQ会先收集这一批次消息的刷盘请求,再进行一次统一的刷盘操作。并且一批消息有可能会跨两个commitlog文件,所以在刷盘时,要严格计算commitlog文件的刷盘次数。 异步刷盘是通过RocketMQ自己实现的一个CountDownLatch2提供了线程阻塞,使用CAS来驱动CountDownLatch2的countDown操作。每来一个消息就启动一次CAS,成功后,调用一次countDown。 这个CountDownLatch2在java.util.concurrent.CountDownLatch的基础上,增加实现了reset功能,实现了对象的重用。
其中主要涉及到是否开启了对外内存。TransientStorePoolEnable。如果开启了堆外内存,会在启动时申请一个跟CommitLog文件大小一致的堆外内存,这部分内存就可以确保不会被交换到虚拟内存中。
3、文件同步刷盘与异步刷盘 入口:CommitLog.submitFlushRequest
主从同步时,也体现到了RocketMQ对于性能的极致追求。最为明显的,RocketMQ整体是基于Netty实现的网络请求,而在主从复制这一块,却放弃了Netty框架,转而使用更轻量级的Java的NIO来构建。
在主要的HAService中,会在启动过程中启动三个守护进程。 //HAService#start public void start() throws Exception { this.acceptSocketService.beginAccept(); this.acceptSocketService.start(); this.groupTransferService.start(); this.haClient.start(); } 这其中与Master相关的是acceptSocketService和groupTransferService。其中acceptSocketService主要负责维护Master与Slave之间的TCP连接。groupTransferService主要与主从同步复制有关。而slave相关的则是haClient。 至于其中关于主从的同步复制与异步复制的实现流程,还是比较复杂的,有兴趣的同学可以深入去研究一下。推荐一篇可供参考的博客 https://blog.csdn.net/qq_25145759/article/details/115865245
4、CommigLog主从复制 入口:CommitLog.submitReplicaRequest
默认情况下, Broker会启动后台线程,每60秒,检查CommitLog、ConsumeQueue文件。然后对超过72小时的数据进行删除。也就是说,默认情况下, RocketMQ只会保存3天内的数据。这个时间可以通过fileReservedTime来配置。注意他删除时,并不会检查消息是否被消费了。
整个文件存储的核心入口入口在DefaultMessageStore的start方法中。
4、过期文件删除 入口: DefaultMessageStore.addScheduleTask -> DefaultMessageStore.this.cleanFilesPeriodically()
2、源码重点:消息存储的入口在:DefaultMessageStore.putMessage
4.1 文件存储
1、关注重点 延迟消息是RocketMQ非常有特色的一个功能,其他MQ产品中,往往需要开发者使用一些特殊方法来变相实现延迟消息功能。而RocketMQ直接在产品中实现了这个功能,开发者只需要设定一个属性就可以快速实现。 延迟消息的核心使用方法就是在Message中设定一个MessageDelayLevel参数,对应18个延迟级别。然后Broker中会创建一个默认的Schedule_Topic主题,这个主题下有18个队列,对应18个延迟级别。消息发过来之后,会先把消息存入Schedule_Topic主题中对应的队列。然后等延迟时间到了,再转发到目标队列,推送给消费者进行消费。
1、消息写入:代码见CommitLog.putMessage方法。在CommitLog写入消息时,会判断消息的延迟级别,然后修改Message的Topic和Queue,达到转储Message的目的。
整个延迟消息的实现方式是这样的:
2、消息转储到目标Topic 这个转储的核心服务是scheduleMessageService,他也是Broker启动过程中的一个功能组件。随DefaultMessageStore组件一起构建。这个服务只在master节点上启动,而在slave节点上会主动关闭这个服务。
2、源码重点 延迟消息的处理入口在scheduleMessageService这个组件中。 他会在broker启动时也一起加载。
4.2、延迟消息
1、功能回顾 RocketMQ对消息消费者提供了Push推模式和Pull拉模式两种消费模式。但是这两种消费模式的本质其实都是Pull拉模式,Push模式可以认为是一种定时的Pull机制。但是这时有一个问题,当使用Push模式时,如果RocketMQ中没有对应的数据,那难道一直进行空轮询吗?如果是这样的话,那显然会极大的浪费网络带宽以及服务器的性能,并且,当有新的消息进来时,RocketMQ也没有办法尽快通知客户端,而只能等客户端下一次来拉取消息了。针对这个问题,RocketMQ实现了一种长轮询机制 long polling。 长轮询机制简单来说,就是当Broker接收到Consumer的Pull请求时,判断如果没有对应的消息,不用直接给Consumer响应(给响应也是个空的,没意义),而是就将这个Pull请求给缓存起来。当Producer发送消息过来时,增加一个步骤去检查是否有对应的已缓存的Pull请求,如果有,就及时将请求从缓存中拉取出来,并将消息通知给Consumer。
2、源码重点 整个流程以及源码重点如下图所示:
4.3、长轮询机制
四、重点业务机制
源码解读
RocketMq
数据分类:结构化数据: 固定格式,有限长度 比如mysql存的数据非结构化数据:不定长,无固定格式 比如邮件,word文档,日志半结构化数据: 前两者结合 比如xml,html搜索分类:结构化数据搜索: 使用关系型数据库非结构化数据搜索顺序扫描全文检索
全文检索是指:通过一个程序扫描文本中的每一个单词,针对单词建立索引,并保存该单词在文本中的位置、以及出现的次数用户查询时,通过之前建立好的索引来查询,将索引中单词对应的文本位置、出现的次数返回给用户,因为有了具体文本的位置,所以就可以将具体内容读取出来了
搜索原理简单概括的话可以分为这么几步:内容爬取,停顿词过滤比如一些无用的像\"的\",“了”之类的语气词/连接词内容分词,提取关键词根据关键词建立倒排索引用户输入关键词进行搜索
什么是全文检索
倒排索引索引就类似于目录,平时我们使用的都是索引,都是通过主键定位到某条数据,那么倒排索引呢,刚好相反,数据对应到主键。
全文检索
ElasticSearch是什么ElasticSearch(简称ES)是一个分布式、RESTful 风格的搜索和数据分析引擎,是用Java开发并且是当前最流行的开源的企业级搜索引擎,能够达到近实时搜索,稳定,可靠,快速,安装使用方便。客户端支持Java、.NET(C#)、PHP、Python、Ruby等多种语言。官方网站: https://www.elastic.co/下载地址:https://www.elastic.co/cn/downloads/past-releases#elasticsearch
起源——Lucene基于Java语言开发的搜索引擎库类创建于1999年,2005年成为Apache 顶级开源项目Lucene具有高性能、易扩展的优点Lucene的局限性︰只能基于Java语言开发类库的接口学习曲线陡峭原生并不支持水平扩展
ES Server进程 3节点 raft (奇数节点) 数据分片 -》lucene实例 分片和副本数 1个ES节点可以有多个lucene实例。也可以指定一个索引的多个分片
Elasticsearch的诞生Elasticsearch是构建在Apache Lucene之上的开源分布式搜索引擎。2004年 Shay Banon 基于Lucene开发了Compass2010年 Shay Banon重写了Compass,取名Elasticsearch支持分布式,可水平扩展降低全文检索的学习曲线,可以被任何编程语言调用Elasticsearch 与 Lucene 核心库竞争的优势在于: 完美封装了 Lucene 核心库,设计了友好的 Restful-API,开发者无需过多关注底层机制,直接开箱即用。分片与副本机制,直接解决了集群下性能与高可用问题。
6.x新特性Lucene 7.x新功能跨集群复制(CCR)索引生命周期管理SQL的支持更友好的的升级及数据迁移在主要版本之间的迁移更为简化,体验升级全新的基于操作的数据复制框架,可加快恢复数据性能优化有效存储稀疏字段的新方法,降低了存储成本在索引时进行排序,可加快排序的查询性能
8.x新特性Rest API相比较7.x而言做了比较大的改动(比如彻底删除_type)默认开启安全配置存储空间优化:对倒排文件使用新的编码集,对于keyword、match_only_text、text类型字段有效,有3.5%的空间优化提升,对于新建索引和segment自动生效。优化geo_point,geo_shape类型的索引(写入)效率:15%的提升。技术预览版KNN API发布,(K邻近算法),跟推荐系统、自然语言排名相关。https://www.elastic.co/guide/en/elastic-stack/current/elasticsearch-breaking-changes.html
ElasticSearch版本特性
ElasticSearch vs Solr Solr 是第一个基于 Lucene 核心库功能完备的搜索引擎产品,诞生远早于 Elasticsearch。
在Elastic Stack之前我们听说过ELK,ELK分别是Elasticsearch,Logstash,Kibana这三款软件在一起的简称,在发展的过程中又有新的成员Beats的加入,就形成了Elastic Stack。
在Elastic Stack生态圈中Elasticsearch作为数据存储和搜索,是生态圈的基石,Kibana在上层提供用户一个可视化及操作的界面,Logstash和Beat可以对数据进行收集。在上图的右侧X-Pack部分则是Elastic公司提供的商业项目。
Elastic Stack介绍
站内搜索日志管理与分析大数据分析应用性能监控机器学习国内现在有大量的公司都在使用 Elasticsearch,包括携程、滴滴、今日头条、饿了么、360安全、小米、vivo等诸多知名公司。除了搜索之外,结合Kibana、Logstash、Beats,Elastic Stack还被广泛运用在大数据近实时分析领域,包括日志分析、指标监控、信息安全等多个领域。它可以帮助你探索海量结构化、非结构化数据,按需创建可视化报表,对监控数据设置报警阈值,甚至通过使用机器学习技术,自动识别异常状况。
ElasticSearch应用场景
ElasticSearch简介
主配置文件elasticsearch.ymlcluster.name当前节点所属集群名称,多个节点如果要组成同一个集群,那么集群名称一定要配置成相同。默认值elasticsearch,生产环境建议根据ES集群的使用目的修改成合适的名字。node.name当前节点名称,默认值当前节点部署所在机器的主机名,所以如果一台机器上要起多个ES节点的话,需要通过配置该属性明确指定不同的节点名称。path.data配置数据存储目录,比如索引数据等,默认值 $ES_HOME/data,生产环境下强烈建议部署到另外的安全目录,防止ES升级导致数据被误删除。path.logs配置日志存储目录,比如运行日志和集群健康信息等,默认值 $ES_HOME/logs,生产环境下强烈建议部署到另外的安全目录,防止ES升级导致数据被误删除。bootstrap.memory_lock配置ES启动时是否进行内存锁定检查,默认值true。ES对于内存的需求比较大,一般生产环境建议配置大内存,如果内存不足,容易导致内存交换到磁盘,严重影响ES的性能。所以默认启动时进行相应大小内存的锁定,如果无法锁定则会启动失败。非生产环境可能机器内存本身就很小,能够供给ES使用的就更小,如果该参数配置为true的话很可能导致无法锁定内存以致ES无法成功启动,此时可以修改为false。network.host配置能够访问当前节点的主机,默认值为当前节点所在机器的本机回环地址127.0.0.1 和[::1],这就导致默认情况下只能通过当前节点所在主机访问当前节点。可以配置为 0.0.0.0 ,表示所有主机均可访问。http.port配置当前ES节点对外提供服务的http端口,默认值 9200discovery.seed_hosts配置参与集群节点发现过程的主机列表,说白一点就是集群中所有节点所在的主机列表,可以是具体的IP地址,也可以是可解析的域名。cluster.initial_master_nodes配置ES集群初始化时参与master选举的节点名称列表,必须与node.name配置的一致。ES集群首次构建完成后,应该将集群中所有节点的配置文件中的cluster.initial_master_nodes配置项移除,重启集群或者将新节点加入某个已存在的集群时切记不要设置该配置项。#ES开启远程访问 network.host: 0.0.0.0
修改JVM配置修改config/jvm.options配置文件,调整jvm堆内存大小vim jvm.options-Xms4g-Xmx4g配置的建议Xms和Xms设置成—样Xmx不要超过机器内存的50%不要超过30GB - https://www.elastic.co/cn/blog/a-heap-of-trouble
Windows直接运行elasticsearch.batLinux(centos7)ES不允许使用root账号启动服务,如果你当前账号是root,则需要创建一个专有账户#非root用户bin/elasticsearch # -d 后台启动bin/elasticsearch -d
注意:es默认不能用root用户启动,生产环境建议为elasticsearch创建用户。#为elaticsearch创建用户并赋予相应权限adduser espasswd eschown -R es:es elasticsearch-17.3
启动ElasticSearch服务
vim config/elasticsearch.yml#添加配置discovery.seed_hosts: [\"127.0.0.1\"]cluster.initial_master_nodes: [\"node-1\"]#或者 单节点(集群单节点)discovery.type: single-node
启动ES服务常见错误解决方案
运行Kibana注意:kibana也需要非root用户启动bin/kibana#后台启动nohup bin/kibana &#查询kibana进程netstat -tunlp | grep 5601访问Kibana: http://localhost:5601/
测试分词效果POST _analyze{ \"analyzer\":\"icu_analyzer\
在线安装#查看已安装插件bin/elasticsearch-plugin list#安装插件bin/elasticsearch-plugin install analysis-icu#删除插件bin/elasticsearch-plugin remove analysis-icu注意:安装和删除完插件后,需要重启ES服务才能生效。
测试分词效果#ES的默认分词设置是standard,会单字拆分POST _analyze{ \"analyzer\":\"standard\
创建索引时可以指定IK分词器作为默认分词器PUT /es_db{ \"settings\" : { \"index\" : { \"analysis.analyzer.default.type\": \"ik_max_word\" } }}
离线安装本地下载相应的插件,解压,然后手动上传到elasticsearch的plugins目录,然后重启ES实例就可以了。比如ik中文分词插件:https://github.com/medcl/elasticsearch-analysis-ik
Elasticsearch安装分词插件Elasticsearch提供插件机制对系统进行扩展以安装analysis-icu这个分词插件为例
ElasticSearch快速开始
关系型数据库 VS ElasticSearch在7.0之前,一个 Index可以设置多个Types目前Type已经被Deprecated,7.0开始,一个索引只能创建一个Type - “_doc”传统关系型数据库和Elasticsearch的区别: Elasticsearch- Schemaless /相关性/高性能全文检索 RDMS —事务性/ Join
索引(Index)一个索引就是一个拥有几分相似特征的文档的集合。比如说,可以有一个客户数据的索引,另一个产品目录的索引,还有一个订单数据的索引。一个索引由一个名字来标识(必须全部是小写字母的),并且当我们要对对应于这个索引中的文档进行索引、搜索、更新和删除的时候,都要使用到这个名字。
Elasticsearch是面向文档的,文档是所有可搜索数据的最小单位。日志文件中的日志项一本电影的具体信息/一张唱片的详细信息MP3播放器里的一首歌/一篇PDF文档中的具体内容
文档会被序列化成JSON格式,保存在Elasticsearch中JSON对象由字段组成每个字段都有对应的字段类型(字符串/数值/布尔/日期/二进制/范围类型)
每个文档都有一个Unique ID可以自己指定ID或者通过Elasticsearch自动生成
一篇文档包含了一系列字段,类似数据库表中的一条记录JSON文档,格式灵活,不需要预先定义格式字段的类型可以指定或者通过Elasticsearch自动推算支持数组/支持嵌套
元数据,用于标注文档的相关信息:_index:文档所属的索引名 _type:文档所属的类型名_id:文档唯—ld_source: 文档的原始Json数据_version: 文档的版本号,修改删除操作_version都会自增1_seq_no: 和_version一样,一旦数据发生更改,数据也一直是累计的。Shard级别严格递增,保证后写入的Doc的_seq_no大于先写入的Doc的_seq_no。_primary_term: _primary_term主要是用来恢复数据时处理当多个文档的_seq_no一样时的冲突,避免Primary Shard上的写入被覆盖。每当Primary Shard发生重新分配时,比如重启,Primary选举等,_primary_term会递增1。
文档元数据
文档(Document)
ElasticSearch基本概念
#创建索引PUT /es_db#创建索引时可以设置分片数和副本数PUT /es_db{ \"settings\" : { \"number_of_shards\
创建索引索引命名必须小写,不能以下划线开头格式: PUT /索引名称
#查询索引GET /es_db#es_db是否存在HEAD /es_db
查询索引格式: GET /索引名称
删除索引格式: DELETE /索引名称DELETE /es_db
ElasticSearch索引操作https://www.elastic.co/guide/en/elasticsearch/reference/7.17/index.html
示例数据PUT /es_db{ \"settings\" : { \"index\" : { \"analysis.analyzer.default.type\": \"ik_max_word\" } }}PUT /es_db/_doc/1{\"name\": \"张三\
注意:POST和PUT都能起到创建/更新的作用,PUT需要对一个具体的资源进行操作也就是要确定id才能进行更新/创建,而POST是可以针对整个资源集合进行操作的,如果不写id就由ES生成一个唯一id进行创建新文档,如果填了id那就针对这个id的文档进行创建/更新
添加(索引)文档 格式: [PUT | POST] /索引名称/[_doc | _create ]/id
# 全量更新,替换整个jsonPUT /es_db/_doc/1{\"name\": \"张三\
使用_update部分更新,格式: POST /索引名称/_update/idupdate不会删除原来的文档,而是实现真正的数据更新# 部分更新:在原有文档上更新# Update -文档必须已经存在,更新只会对相应字段做增量修改POST /es_db/_update/1{ \"doc\": { \"age\": 28 }}#查询文档GET /es_db/_doc/1
使用 _update_by_query 更新文档POST /es_db/_update_by_query{ \"query\": { \"match\": { \"_id\
如果版本号不对,会抛出版本冲突异常,如下图:
并发场景下修改文档_seq_no和_primary_term是对_version的优化,7.X版本的ES默认使用这种方式控制版本,所以当在高并发环境下使用乐观锁机制修改文档时,要带上当前文档的_seq_no和_primary_term进行更新:POST /es_db/_doc/2?if_seq_no=21&if_primary_term=6{ \"name\": \"李四xxx\"}
修改文档 全量更新,整个json都会替换,格式: [PUT | POST] /索引名称/_doc/id如果文档存在,现有文档会被删除,新的文档会被索引
根据id查询文档,格式: GET /索引名称/_doc/idGET /es_db/_doc/1
条件查询 _search,格式: /索引名称/_doc/_search# 查询前10条文档GET /es_db/_doc/_search
REST风格的请求URI,直接将参数带过去封装到request body中,这种方式可以定义更加易读的JSON格式
GET /es_db/_search{ \"query\": { \"match\": { \"address\": \"广州白云\" } }}
ES Search API提供了两种条件查询搜索方式:
查询文档
删除文档格式: DELETE /索引名称/_doc/idDELETE /es_db/_doc/1
{\"actionName\":{\"_index\":\"indexName\
批量创建文档createPOST _bulk{\"create\":{\"_index\":\"article\
批量删除deletePOST _bulk{\"delete\":{\"_index\":\"article\
批量修改updatePOST _bulk{\"update\":{\"_index\":\"article\
组合应用POST _bulk{\"create\":{\"_index\":\"article\
_mget#可以通过ID批量获取不同index和type的数据GET _mget{\"docs\": [{\"_index\": \"es_db\
_msearch在_msearch中,请求格式和bulk类似。查询一条数据需要两个对象,第一个设置index和type,第二个设置查询语句。查询语句和search相同。如果只是查询一个index,我们可以在url中带上index,这样,如果查该index可以直接用空对象表示。GET /es_db/_msearch{}{\"query\" : {\"match_all\
批量读取es的批量查询可以使用mget和msearch两种。其中mget是需要我们知道它的id,可以指定不同的index,也可以指定返回值source。msearch可以通过字段查询来进行一个批量的查找。
ElasticSearch文档批量操作批量操作可以减少网络连接所产生的开销,提升性能支持在一次API调用中,对不同的索引进行操作可以在URI中指定Index,也可以在请求的Payload中进行操作中单条操作失败,并不会影响其他操作返回结果包括了每一条操作执行的结果
ElasticSearch文档操作
单词词典(Term Dictionary) :记录所有文档的单词,记录单词到倒排列表的关联关系常用字典数据结构:https://www.cnblogs.com/LBSer/p/4119841.html
倒排列表(Posting List)-记录了单词对应的文档结合,由倒排索引项组成倒排索引项(Posting): 文档ID 词频TF–该单词在文档中出现的次数,用于相关性评分 位置(Position)-单词在文档中分词的位置。用于短语搜索(match phrase query) 偏移(Offset)-记录单词的开始结束位置,实现高亮显示
Elasticsearch 的JSON文档中的每个字段,都有自己的倒排索引。可以指定对某些字段不做索引:优点︰节省存储空间缺点: 字段无法被搜索
ES倒排索引
Mapping类似数据库中的schema的定义,作用如下:定义索引中的字段的名称定义字段的数据类型,例如字符串,数字,布尔等字段,倒排索引的相关配置(Analyzer)
动态映射:在关系数据库中,需要事先创建数据库,然后在该数据库下创建数据表,并创建表字段、类型、长度、主键等,最后才能基于表插入数据。而Elasticsearch中不需要定义Mapping映射(即关系型数据库的表、字段等),在文档写入Elasticsearch时,会根据文档字段自动识别类型,这种机制称之为动态映射。
动态映射(Dynamic Mapping)的机制,使得我们无需手动定义Mappings,Elasticsearch会自动根据文档信息,推算出字段的类型。但是有时候会推算的不对,例如地理位置信息。当类型如果设置不对时,会导致一些功能无法正常运行,例如Range查询
静态映射:静态映射是在Elasticsearch中也可以事先定义好映射,包含文档的各字段类型、分词器等,这种方式称之为静态映射。
Dynamic Mapping类型自动识别:
ES中Mapping映射可以分为动态映射和静态映射。
新增加字段 dynamic设为true时,一旦有新增字段的文档写入,Mapping 也同时被更新 dynamic设为false,Mapping 不会被更新,新增字段的数据无法被索引,但是信息会出现在_source中 dynamic设置成strict(严格控制策略),文档写入失败,抛出异常 true false strict文档可索引 yes yes no字段可索引 yes no noMapping被更新 yes no no
原因:如果修改了字段的数据类型,会导致已被索引的数据无法被搜索但是如果是增加新的字段,就不会有这样的影响
dynamic设置成strict,新增age字段导致文档插入失败
修改dynamic后再次插入文档成功#修改daynamicPUT /user/_mapping{ \"dynamic\":true}
测试PUT /user{ \"mappings\": { \"dynamic\": \"strict\
对已有字段,一旦已经有数据写入,就不再支持修改字段定义Lucene 实现的倒排索引,一旦生成后,就不允许修改如果希望改变字段类型,可以利用 reindex API,重建索引
PUT /user2{ \"mappings\": { \"properties\": { \"name\": { \"type\": \"text\
对已有字段的mapping修改具体方法:
思考:能否后期更改Mapping的字段类型?两种情况:
DELETE /userPUT /user{ \"mappings\" : { \"properties\" : { \"address\" : { \"type\" : \"text\
index: 控制当前字段是否被索引,默认为true。如果设置为false,该字段不可被搜索
text类型默认记录postions,其他默认为 docs。记录内容越多,占用存储空间越大DELETE /userPUT /user{ \"mappings\" : { \"properties\" : { \"address\" : { \"type\" : \"text\
有四种不同基本的index options配置,控制倒排索引记录的内容:docs : 记录doc idfreqs:记录doc id 和term frequencies(词频)positions: 记录doc id / term frequencies / term positionoffsets: doc id / term frequencies / term posistion / character offsets
DELETE /userPUT /user{ \"mappings\" : { \"properties\" : { \"address\" : { \"type\" : \"keyword\
null_value: 需要对Null值进行搜索,只有keyword类型支持设计Null_Value
# 设置copy_toDELETE /addressPUT /address{ \"mappings\" : { \"properties\" : { \"province\" : { \"type\" : \"keyword\
copy_to设置:将字段的数值拷贝到目标字段,满足一些特定的搜索需求。copy_to的目标字段不出现在_source中。
常用Mapping参数配置
模版仅在一个索引被新创建时,才会产生作用。修改模版不会影响已创建的索引你可以设定多个索引模版,这些设置会被“merge”在一起你可以指定“order”的数值,控制“merging”的过程
PUT /_template/template_default{ \"index_patterns\": [\"*\
Index Templates可以帮助你设定Mappings和Settings,并按照一定的规则,自动匹配到新创建的索引之上
当一个索引被新创建时:应用Elasticsearch 默认的settings 和mappings 应用order数值低的lndex Template 中的设定应用order高的 Index Template 中的设定,之前的设定会被覆盖应用创建索引时,用户所指定的Settings和 Mappings,并覆盖之前模版中的设定
#查看template信息GET /_template/template_defaultGET /_template/temp*PUT /testtemplate/_doc/1{ \"orderNo\
lndex Template的工作方式
Index Template
#Dynaminc Mapping 根据类型和字段名DELETE my_indexPUT my_index/_doc/1{ \"firstName\":\"Ruan\
Dynamic Tempate定义在某个索引的Mapping中。
Dynamic Template
文档映射Mapping
GET /es_db/_doc/_search {json请求体数据}可以简化为下面写法GET /es_db/_search {json请求体数据}
示例#无条件查询,默认返回10条数据GET /es_db/_search{\"query\":{\"match_all\":{}}}
#指定ik分词器PUT /es_db{ \"settings\" : { \"index\" : { \"analysis.analyzer.default.type\": \"ik_max_word\
示例
GET /es_db/_search等同于GET /es_db/_search{\"query\":{\"match_all\":{}}}
使用match_all,默认只会返回10条数据。原因:_search查询默认采用的是分页查询,每页记录数size的默认值为10。如果想显示更多数据,指定size
size 关键字: 指定查询结果中返回指定条数。 默认返回值10条GET /es_db/_search{ \"query\": { \"match_all\
返回指定条数size
注意:参数index.max_result_window主要用来限制单次查询满足查询条件的结果窗口的大小,窗口大小由from + size共同决定。不能简单理解成查询返回给调用方的数据量。这样做主要是为了限制内存的消耗。比如:from为1000000,size为10,逻辑意义是从满足条件的数据中取1000000到(1000000 + 10)的记录。这时ES一定要先将(1000000 + 10)的记录(即result_window)加载到内存中,再进行分页取值的操作。尽管最后我们只取了10条数据返回给客户端,但ES进程执行查询操作的过程中确需要将(1000000 + 10)的记录都加载到内存中,可想而知对内存的消耗有多大。这也是ES中不推荐采用(from + size)方式进行深度分页的原因。同理,from为0,size为1000000时,ES进程执行查询操作的过程中确需要将1000000 条记录都加载到内存中再返回给调用方,也会对ES内存造成很大压力。
异常原因:1、查询结果的窗口太大,from + size的结果必须小于或等于10000,而当前查询结果的窗口为20000。2、可以采用scroll api更高效的请求大量数据集。3、查询结果的窗口的限制可以通过参数index.max_result_window进行设置。PUT /es_db/_settings{ \"index.max_result_window\" :\"20000\"}#修改现有所有的索引,但新增的索引,还是默认的10000PUT /_all/_settings{ \"index.max_result_window\" :\"20000\"}#查看所有索引中的index.max_result_window值GET /_all/_settings/index.max_result_window
思考: size可以无限增加吗?测试GET /es_db/_search{ \"query\": { \"match_all\
from 关键字: 用来指定起始返回位置,和size关键字连用可实现分页效果GET /es_db/_search{ \"query\": { \"match_all\
分页查询from
改动index.max_result_window参数值的大小,只能解决一时的问题,当索引的数据量持续增长时,在查询全量数据时还是会出现问题。而且会增加ES服务器内存大结果集消耗完的风险。最佳实践还是根据异常提示中的采用scroll api更高效的请求大量数据集。
查询结果:除了返回前2条记录,还返回了一个游标ID值_scroll_id。
采用游标id查询:# scroll_id 的值就是上一个请求中返回的 _scroll_id 的值GET /_search/scroll{ \"scroll\": \"1m\
多次根据scroll_id游标查询,直到没有数据返回则结束查询。采用游标查询索引全量数据,更安全高效,限制了单次对内存的消耗。
深分页查询Scroll
GET /es_db/_search{ \"query\": { \"match_all\
注意:会让得分失效
指定字段排序sort
查询所有match_all
#模糊匹配 match 分词后or的效果GET /es_db/_search{ \"query\": { \"match\": { \"address\": \"广州白云山公园\" } }}# 分词后 and的效果GET /es_db/_search{ \"query\": { \"match\": { \"address\": { \"query\": \"广州白云山公园\
match支持以下参数:query : 指定匹配的值 operator : 匹配条件类型 and : 条件分词后都要匹配 or : 条件分词后有一个匹配即可(默认)minmum_should_match : 最低匹配度,即条件在倒排索引中最低的匹配度
# 最少匹配广州,公园两个词GET /es_db/_search{ \"query\": { \"match\": { \"address\": { \"query\": \"广州公园\
在match中的应用: 当operator参数设置为or时,minnum_should_match参数用来控制匹配的分词的最少数量。
matchmatch在匹配时会对所查找的关键词进行分词,然后按分词匹配查找
GET /es_db/_search{ \"query\": { \"match_phrase\": { \"address\": \"广州白云山\" } }}GET /es_db/_search{ \"query\": { \"match_phrase\": { \"address\": \"广州白云\" } }}思考:为什么查询广州白云山有数据,广州白云没有数据?
分析原因:先查看广州白云山公园分词结果,可以知道广州和白云不是相邻的词条,中间会隔一个白云山,而match_phrase匹配的是相邻的词条,所以查询广州白云山有结果,但查询广州白云没有结果。
POST _analyze{ \"analyzer\":\"ik_max_word\
如何解决词条间隔的问题?可以借助slop参数,slop参数告诉match_phrase查询词条能够相隔多远时仍然将文档视为匹配。
#广州云山分词后相隔为2,可以匹配到结果GET /es_db/_search{ \"query\": { \"match_phrase\": { \"address\": { \"query\": \"广州云山\
match_phrase查询分析文本并根据分析的文本创建一个短语查询。match_phrase 会将检索关键词分词。match_phrase的分词结果必须在被检索字段的分词中都包含,而且顺序必须相同,而且默认必须都是连续的。
短语查询match_phrase
可以根据字段类型,决定是否使用分词查询,得分最高的在前面GET /es_db/_search{ \"query\": { \"multi_match\": { \"query\": \"长沙张龙\
多字段查询multi_match
允许我们在单个查询字符串中指定AND | OR | NOT条件,同时也和 multi_match query 一样,支持多字段搜索。和match类似,但是match需要指定字段名,query_string是在所有字段中搜索,范围更广泛。注意: 查询字段分词就将查询条件分词查询,查询字段不分词将查询条件不分词查询
未指定字段查询GET /es_db/_search{ \"query\": { \"query_string\": { \"query\": \"张三 OR 橘子洲\" } }}
指定单个字段查询#Query StringGET /es_db/_search{ \"query\": { \"query_string\": { \"default_field\": \"address\
指定多个字段查询GET /es_db/_search{ \"query\": { \"query_string\": { \"fields\": [\"name\
query_string
#simple_query_string 默认的operator是ORGET /es_db/_search{ \"query\": { \"simple_query_string\": { \"fields\": [\"name\
simple_query_string
#关键字查询 term# 思考: 查询广州白云是否有数据,为什么?GET /es_db/_search{ \"query\":{ \"term\": { \"address\": { \"value\": \"广州白云\
在ES中,Term查询,对输入不做分词。会将输入作为一个整体,在倒排索引中查找准确的词项,并且使用相关度算分公式为每个包含该词项的文档进行相关度算分。
PUT /product/_bulk{\"index\":{\"_id\":1}}{\"productId\":\"xxx123\
可以通过 Constant Score 将查询转换成一个 Filtering,避免算分,并利用缓存,提高性能。将Query 转成 Filter,忽略TF-IDF计算,避免相关性算分的开销Filter可以有效利用缓存
GET /es_db/_search{ \"query\": { \"constant_score\": { \"filter\": { \"term\": { \"address.keyword\": \"广州白云山公园\" } } } }}
关键词查询Term
结构化搜索(Structured search)是指对结构化数据的搜索。
结构化数据: 日期,布尔类型和数字都是结构化的 文本也可以是结构化的。 如彩色笔可以有离散的颜色集合:红(red) 、绿(green、蓝(blue) 一个博客可能被标记了标签,例如,分布式(distributed)和搜索(search) 电商网站上的商品都有UPC(通用产品码Universal Product Code)或其他的唯一标识,它们都需要遵从严格规定的、结构化的格式。
GET /es_db/_search{ \"query\": { \"term\": { \"age\": { \"value\": 28 } } }}
应用场景:对bool,日期,数字,结构化的文本可以利用term做精确匹配
POST /employee/_bulk{\"index\":{\"_id\":1}}{\"name\":\"小明\
term处理多值字段,term查询是包含,不是等于
ES中的结构化搜索
它会对分词后的term进行前缀搜索。它不会分析要搜索字符串,传入的前缀就是想要查找的前缀默认状态下,前缀查询不做相关度分数计算,它只是将所有匹配的文档返回,然后赋予所有相关分数值为1。它的行为更像是一个过滤器而不是查询。两者实际的区别就是过滤器是可以被缓存的,而前缀查询不行。
GET /es_db/_search{ \"query\": { \"prefix\": { \"address\": { \"value\": \"广州\" } } }}
prefix的原理:需要遍历所有倒排索引,并比较每个term是否已所指定的前缀开头。
前缀查询prefix
通配符查询:工作原理和prefix相同,只不过它不是只比较开头,它能支持更为复杂的匹配模式。GET /es_db/_search{ \"query\": { \"wildcard\": { \"address\": { \"value\": \"*白*\" } } }}
通配符查询wildcard
range:范围关键字gte 大于等于lte 小于等于gt 大于lt 小于now 当前时间POST /es_db/_search{ \"query\": { \"range\": { \"age\": { \"gte\
DELETE /productPOST /product/_bulk{\"index\":{\"_id\":1}}{\"price\
日期range
范围查询range
多id查询ids
在实际的搜索中,我们有时候会打错字,从而导致搜索不到。在Elasticsearch中,我们可以使用fuzziness属性来进行模糊查询,从而达到搜索有错别字的情形。
GET /es_db/_search{ \"query\": { \"fuzzy\": { \"address\": { \"value\": \"白运山\
注意: fuzzy 模糊查询 最大模糊错误 必须在0-2之间搜索关键词长度为 2,不允许存在模糊搜索关键词长度为3-5,允许1次模糊搜索关键词长度大于5,允许最大2次模糊
fuzzy 查询会用到两个很重要的参数,fuzziness,prefix_lengthfuzziness:表示输入的关键字通过几次操作可以转变成为ES库里面的对应field的字段 操作是指:新增一个字符,删除一个字符,修改一个字符,每次操作可以记做编辑距离为1, 如中文集团到中威集团编辑距离就是1,只需要修改一个字符; 该参数默认值为0,即不开启模糊查询。 如果fuzziness值在这里设置成2,会把编辑距离为2的东东集团也查出来。prefix_length:表示限制输入关键字和ES对应查询field的内容开头的第n个字符必须完全匹配,不允许错别字匹配 如这里等于1,则表示开头的字必须匹配,不匹配则不返回 默认值也是0 加大prefix_length的值可以提高效率和准确率。
模糊查询fuzzy
示例数据#指定ik分词器PUT /products{ \"settings\" : { \"index\" : { \"analysis.analyzer.default.type\": \"ik_max_word\" } }}PUT /products/_doc/1{ \"proId\" : \"2\
测试GET /products/_search{ \"query\": { \"term\": { \"name\": { \"value\": \"牛仔\
highlight 关键字: 可以让符合条件的文档中的关键词高亮。highlight相关属性:pre_tags 前缀标签post_tags 后缀标签tags_schema 设置为styled可以使用内置高亮样式require_field_match 多字段高亮需要设置为false
可以在highlight中使用pre_tags和post_tagsGET /products/_search{ \"query\": { \"term\": { \"name\": { \"value\": \"牛仔\
自定义高亮html标签
GET /products/_search{ \"query\": { \"term\": { \"name\": { \"value\": \"牛仔\
多字段高亮
高亮highlight
ES高级查询Query DSL
高级查询语法
入门实战
搜索是用户和搜索引擎的对话,用户关心的是搜索结果的相关性是否可以找到所有相关的内容有多少不相关的内容被返回了文档的打分是否合理结合业务需求,平衡结果排名
如何衡量相关性:Precision(查准率)―尽可能返回较少的无关文档Recall(查全率)–尽量返回较多的相关文档Ranking -是否能够按照相关度进行排序
搜索的相关性算分,描述了一个文档和查询语句匹配的程度。ES 会对每个匹配查询条件的结果进行算分_score。打分的本质是排序,需要把最符合用户需求的文档排在前面。ES 5之前,默认的相关性算分采用TF-IDF,现在采用BM 25。
相关性(Relevance)
TF-IDF(term frequency–inverse document frequency)是一种用于信息检索与数据挖掘的常用加权技术。
TF-IDF被公认为是信息检索领域最重要的发明,除了在信息检索,在文献分类和其他相关领域有着非常广泛的应用。IDF的概念,最早是剑桥大学的“斯巴克.琼斯”提出1972年——“关键词特殊性的统计解释和它在文献检索中的应用”,但是没有从理论上解释IDF应该是用log(全部文档数/检索词出现过的文档总数),而不是其他函数,也没有做进一步的研究1970,1980年代萨尔顿和罗宾逊,进行了进一步的证明和研究,并用香农信息论做了证明http://www.staff.city.ac.uk/~sb317/papers/foundations_bm25_review.pdf现代搜索引擎,对TF-IDF进行了大量细微的优化
TF是词频(Term Frequency)检索词在文档中出现的频率越高,相关性也越高。
IDF是逆向文本频率(Inverse Document Frequency)每个检索词在索引中出现的频率,频率越高,相关性越低。
字段长度归一值( field-length norm)字段的长度是多少?字段越短,字段的权重越高。检索词出现在一个内容短的 title 要比同样的词出现在一个内容长的 content 字段权重更大。以上三个因素——词频(term frequency)、逆向文档频率(inverse document frequency)和字段长度归一值(field-length norm)——是在索引时计算并存储的,最后将它们结合在一起计算单个词在特定文档中的权重。
Lucene中的TF-IDF评分公式:
TF-IDF
BM25 就是对 TF-IDF 算法的改进,对于 TF-IDF 算法,TF(t) 部分的值越大,整个公式返回的值就会越大。BM25 就针对这点进行来优化,随着TF(t) 的逐步加大,该算法的返回值会趋于一个数值。
BM25
示例:PUT /test_score/_bulk{\"index\":{\"_id\":1}}{\"content\":\"we use Elasticsearch to power the search\"}{\"index\":{\"_id\":2}}{\"content\":\"we like elasticsearch\"}{\"index\":{\"_id\":3}}{\"content\":\"Thre scoring of documents is caculated by the scoring formula\"}{\"index\":{\"_id\":4}}{\"content\":\
通过Explain API查看TF-IDF
Boosting是控制相关度的一种手段。
参数boost的含义:当boost > 1时,打分的权重相对性提升当0 < boost <1时,打分的权重相对性降低当boost <0时,贡献负分
GET /test_score/_search{ \"query\": { \"boosting\": { \"positive\": { \"term\": { \"content\": \"elasticsearch\
应用场景:希望包含了某项内容的结果不是不出现,而是排序靠后。
Boosting
相关性和相关性算分
must: 相当于&& ,必须匹配,贡献算分should: 相当于|| ,选择性匹配,贡献算分must_not: 相当于! ,必须不能匹配,不贡献算分filter: 必须匹配,不贡献算法
相关性并不只是全文本检索的专利,也适用于yes | no 的子句,匹配的子句越多,相关性评分越高。如果多条查询子句被合并为一条复合查询语句,比如 bool查询,则每个查询子句计算得出的评分会被合并到总的相关性评分中。
GET /es_db/_search{ \"query\": { \"bool\": { \"must\": { \"match\": { \"remark\": \"java developer\
测试数据POST /employee/_bulk{\"index\":{\"_id\":1}}{\"name\":\"小明\
从业务角度,按需改进Elasticsearch数据模型POST /employee/_bulk{\"index\":{\"_id\":1}}{\"name\":\"小明\
使用bool查询# must 算分POST /employee/_search{ \"query\": { \"bool\": { \"must\": [ { \"term\": { \"interest.keyword\": { \"value\": \"跑步\
如何解决结构化查询“包含而不是相等”的问题
利用bool嵌套实现should not逻辑
布尔查询bool Query
POST /blogs/_bulk{\"index\":{\"_id\":1}}{\"title\":\"Apple iPad\
Boosting是控制相关的一种手段。可以通过指定字段的boost值影响查询结果参数boost的含义:当boost > 1时,打分的权重相对性提升当0 < boost <1时,打分的权重相对性降低当boost <0时,贡献负分
POST /news/_bulk{\"index\":{\"_id\":1}}{\"content\":\"Apple Mac\"}{\"index\":{\"_id\":2}}{\"content\":\"Apple iPad\"}{\"index\":{\"_id\":3}}{\"content\":\"Apple employee like Apple Pie and Apple Juice\"}GET /news/_search{ \"query\": { \"bool\": { \"must\": { \"match\": { \"content\": \"apple\" } } } }}
案例:要求苹果公司的产品信息优先展示
GET /news/_search{ \"query\": { \"bool\": { \"must\": { \"match\": { \"content\": \"apple\
利用must not排除不是苹果公司产品的文档
GET /news/_search{ \"query\": { \"boosting\": { \"positive\": { \"match\": { \"content\": \"apple\
控制字段的Boosting
Boosting Query
三种场景最佳字段(Best Fields)当字段之间相互竞争,又相互关联。例如,对于博客的 title和 body这样的字段,评分来自最匹配字段多数字段(Most Fields)处理英文内容时的一种常见的手段是,在主字段( English Analyzer),抽取词干,加入同义词,以匹配更多的文档。相同的文本,加入子字段(Standard Analyzer),以提供更加精确的匹配。其他字段作为匹配文档提高相关度的信号,匹配字段越多则越好。混合字段(Cross Field)对于某些实体,例如人名,地址,图书信息。需要在多个字段中确定信息,单个字段只能作为整体的一部分。希望在任何这些列出的字段中找到尽可能多的词
PUT /blogs/_doc/1{ \"title\": \"Quick brown rabbits\
将任何与任一查询匹配的文档作为结果返回,采用字段上最匹配的评分最终评分返回。官方文档:https://www.elastic.co/guide/en/elasticsearch/reference/7.17/query-dsl-dis-max-query.html测试
思考:查询结果不符合预期,为什么?
bool should的算法过程:查询should语句中的两个查询加和两个查询的评分乘以匹配语句的总数除以所有语句的总数上述例子中,title和body属于竞争关系,不应该讲分数简单叠加,而是应该找到单个最佳匹配的字段的评分。
POST blogs/_search{ \"query\": { \"dis_max\": { \"queries\": [ { \"match\": { \"title\": \"Brown fox\
使用最佳字段查询dis max query
POST /blogs/_search{ \"query\": { \"dis_max\": { \"queries\": [ { \"match\": { \"title\": \"Quick pets\
可以通过tie_breaker参数调整Tier Breaker是一个介于0-1之间的浮点数。0代表使用最佳匹配;1代表所有语句同等重要。获得最佳匹配语句的评分_score 。将其他匹配语句的评分与tie_breaker相乘对以上评分求和并规范化
最佳字段查询Dis Max Query
POST /blogs/_search{ \"query\": { \"multi_match\": { \"type\": \"best_fields\
最佳字段(Best Fields)搜索Best Fields是默认类型,可以不用指定
DELETE /titlesPUT /titles{ \"mappings\": { \"properties\": { \"title\": { \"type\": \"text\
用广度匹配字段title包括尽可能多的文档——以提升召回率——同时又使用字段title.std 作为信号将相关度更高的文档置于结果顶部。
GET /titles/_search{ \"query\": { \"multi_match\": { \"query\": \"barking dogs\
#增加title的权重GET /titles/_search{ \"query\": { \"multi_match\": { \"query\": \"barking dogs\
使用多数字段(Most Fields)搜索案例
DELETE /addressPUT /address{ \"settings\" : { \"index\" : { \"analysis.analyzer.default.type\": \"ik_max_word\" } }}PUT /address/_bulk{ \"index\": { \"_id\": \"1\"} }{\"province\": \"湖南\
DELETE /addressPUT /address{ \"mappings\" : { \"properties\" : { \"province\" : { \"type\" : \"keyword\
跨字段(Cross Field)搜索
Multi Match Query
单字符串多字段查询
Elasticsearch除搜索以外,提供了针对ES 数据进行统计分析的功能。聚合(aggregations)可以让我们极其方便的实现对数据的统计、分析、运算。例如:什么品牌的手机最受欢迎?这些手机的平均价格、最高价格、最低价格?这些手机每月的销售情况如何?
语法:\"aggs\" : { #和query同级的关键词 \"<aggregation_name>\" : { #自定义的聚合名字 \"<aggregation_type>\
ELECT size COUNT(*) FROM products GROUP BY size#bucket聚合的DSL类比实现:{ \"aggs\": { \"by_size\": { \"terms\": { \"field\": \"size\" } }}
Bucket Aggregation: 一些满足特定条件的文档的集合放置到一个桶里,每一个桶关联一个key,类比Mysql中的group by操作。
Pipeline Aggregation:对其他的聚合结果进行二次聚合
聚合的分类
示例数据DELETE /employees#创建索引库PUT /employees{ \"mappings\": { \"properties\": { \"age\":{ \"type\": \"integer\
查询员工的最低最高和平均工资#多个 Metric 聚合,找到最低最高和平均工资POST /employees/_search{ \"size\
对salary进行统计# 一个聚合,输出多值POST /employees/_search{ \"size\
cardinate对搜索结果去重POST /employees/_search{ \"size\
Metric Aggregation
按照一定的规则,将文档分配到不同的桶中,从而达到分类的目的。ES提供的一些常见的 Bucket Aggregation。Terms,需要字段支持filedata keyword 默认支持fielddata text需要在Mapping 中开启fielddata,会按照分词后的结果进行分桶数字类型 Range / Data Range Histogram(直方图) / Date Histogram支持嵌套: 也就在桶里再做分桶
# 对keword 进行聚合GET /employees/_search{ \"size\
获取job的分类信息
GET /employees/_search{ \"size\
聚合可配置属性有:field:指定聚合字段size:指定聚合结果数量order:指定聚合结果排序方式默认情况下,Bucket聚合会统计Bucket内的文档数量,记为_count,并且按照_count降序排序。我们可以指定order属性,自定义聚合的排序方式:
#只对salary在10000元以上的文档聚合GET /employees/_search{ \"query\": { \"range\": { \"salary\": { \"gte\
限定聚合范围
POST /employees/_search{ \"size\
注意:对 Text 字段进行 terms 聚合查询,会失败抛出异常
PUT /employees/_mapping{ \"properties\" : { \"job\":{ \"type\": \"text\
解决办法:对 Text 字段打开 fielddata,支持terms aggregation
对job.keyword 和 job 进行 terms 聚合,分桶的总数并不一样
Range 示例:按照工资的 Range 分桶Salary Range分桶,可以自己定义 keyPOST employees/_search{ \"size\
Range & Histogram聚合按照数字的范围,进行分桶在Range Aggregation中,可以自定义Key
#工资0到10万,以 5000一个区间进行分桶POST employees/_search{ \"size\
Histogram示例:按照工资的间隔分桶
# 指定size,不同工种中,年纪最大的3个员工的具体信息POST /employees/_search{ \"size\
top_hits应用场景: 当获取分桶后,桶内最匹配的顶部文档列表
# 嵌套聚合1,按照工作类型分桶,并统计工资信息POST employees/_search{ \"size\
嵌套聚合示例
Bucket Aggregation
Sibling - 结果和现有分析结果同级 Max,min,Avg & Sum Bucket Stats,Extended Status Bucket Percentiles BucketParent -结果内嵌到现有的聚合分析结果之中 Derivative(求导) Cumultive Sum(累计求和) Moving Function(移动平均值 )
支持对聚合分析的结果,再次进行聚合分析。Pipeline 的分析结果会输出到原结果中,根据位置的不同,分为两类
# 平均工资最低的工种POST employees/_search{ \"size\
在员工数最多的工种里,找出平均工资最低的工种
min_salary_by_job结果和jobs的聚合同级min_bucket求之前结果的最小值通过bucket_path关键字指定路径
min_bucket示例
# 平均工资的统计分析POST employees/_search{ \"size\
Stats示例
# 平均工资的百分位数POST employees/_search{ \"size\
percentiles示例
#Cumulative_sum 累计求和POST employees/_search{ \"size\
Cumulative_sum示例
Pipeline Aggregation
#QueryPOST employees/_search{ \"size\
ES聚合分析的默认作用范围是query的查询结果集,同时ES还支持以下方式改变聚合的作用范围:FilterPost FilterGlobal
聚合的作用范围
#排序 order#count and keyPOST employees/_search{ \"size\
指定order,按照count和key进行排序:默认情况,按照count降序排序指定size,就能返回相应的桶
排序
ElasticSearch在对海量数据进行聚合分析的时候会损失搜索的精准度来满足实时性的需求。
Terms聚合分析的执行流程:
不精准的原因: 数据分散到多个分片,聚合是每个分片的取 Top X,导致结果不精准。ES 可以不每个分片Top X,而是全量聚合,但势必这会有很大的性能问题。
思考:如何提高聚合精确度?
方案1:设置主分片为1注意7.x版本已经默认为1。适用场景:数据量小的小集群规模业务场景。
size:是聚合结果的返回值,客户期望返回聚合排名前三,size值就是 3。shard_size: 每个分片上聚合的数据条数。shard_size 原则上要大于等于 size适用场景:数据量大、分片数多的集群业务场景。
测试: 使用kibana的测试数据DELETE my_flightsPUT my_flights{ \"settings\": { \"number_of_shards\
在Terms Aggregation的返回中有两个特殊的数值:doc_count_error_upper_bound : 被遗漏的term 分桶,包含的文档,有可能的最大值sum_other_doc_count: 除了返回结果 bucket的terms以外,其他 terms 的文档总数(总数-返回的总数)
方案2:调大 shard_size 值设置 shard_size 为比较大的值,官方推荐:size*1.5+10。shard_size 值越大,结果越趋近于精准聚合结果值。此外,还可以通过show_term_doc_count_error参数显示最差情况下的错误值,用于辅助确定 shard_size 大小。
方案3:将size设置为全量值,来解决精度问题将size设置为2的32次方减去1也就是分片支持的最大值,来解决精度问题。原因:1.x版本,size等于 0 代表全部,高版本取消 0 值,所以设置了最大值(大于业务的全量值)。全量带来的弊端就是:如果分片数据量极大,这样做会耗费巨大的CPU 资源来排序,而且可能会阻塞网络。适用场景:对聚合精准度要求极高的业务场景,由于性能问题,不推荐使用。
方案4:使用Clickhouse/ Spark 进行精准聚合适用场景:数据量非常大、聚合精度要求高、响应速度快的业务场景。
ES聚合分析不精准原因分析
ElasticSearch聚合操作
适用场景:高基数聚合 。高基数聚合场景中的高基数含义:一个字段包含很大比例的唯一值。global ordinals 中文翻译成全局序号,是一种数据结构,应用场景如下: 基于 keyword,ip 等字段的分桶聚合,包含:terms聚合、composite 聚合等。 基于text 字段的分桶聚合(前提条件是:fielddata 开启)。 基于父子文档 Join 类型的 has_child 查询和 父聚合。global ordinals 使用一个数值代表字段中的字符串值,然后为每一个数值分配一个 bucket(分桶)。global ordinals 的本质是:启用 eager_global_ordinals 时,会在刷新(refresh)分片时构建全局序号。这将构建全局序号的成本从搜索阶段转移到了数据索引化(写入)阶段。
PUT /my-index{ \"mappings\": { \"properties\": { \"tags\": { \"type\": \"keyword\
注意:开启 eager_global_ordinals 会影响写入性能,因为每次刷新时都会创建新的全局序号。为了最大程度地减少由于频繁刷新建立全局序号而导致的额外开销,请调大刷新间隔 refresh_interval。
创建索引的同时开启:eager_global_ordinals。
PUT my-index/_settings{ \"index\": { \"refresh_interval\": \"30s\" }该招数的本质是:以空间换时间。
动态调整刷新频率的方法如下:
启用 eager global ordinals 提升高基数聚合性能
PUT /my_index{ \"settings\": { \"index\":{ \"sort.field\": \"create_time\
注意:预排序将增加 Elasticsearch 写入的成本。在某些用户特定场景下,开启索引预排序会导致大约 40%-50% 的写性能下降。也就是说,如果用户场景更关注写性能的业务,开启索引预排序不是一个很好的选择。
Index sorting (索引排序)可用于在插入时对索引进行预排序,而不是在查询时再对索引进行排序,这将提高范围查询(range query)和排序操作的性能。在 Elasticsearch 中创建新索引时,可以配置如何对每个分片内的段进行排序。这是 Elasticsearch 6.X 之后版本才有的特性。
插入数据时对索引进行预排序
节点查询缓存(Node query cache)可用于有效缓存过滤器(filter)操作的结果。如果多次执行同一 filter 操作,这将很有效,但是即便更改过滤器中的某一个值,也将意味着需要计算新的过滤器结果。
PUT /my_index/_doc/1{ \"create_time\":\"2022-05-11T16:30:55.328Z\"}#下面的示例无法使用缓存GET /my_index/_search{ \"query\":{ \"constant_score\": { \"filter\": { \"range\": { \"create_time\": { \"gte\": \"now-1h\
上述示例中的“now-1h/m” 就是 datemath 的格式。如果当前时间 now 是:16:31:29,那么range query 将匹配 my_date 介于:15:31:00 和 15:31:59 之间的时间数据。同理,聚合的前半部分 query 中如果有基于时间查询,或者后半部分 aggs 部分中有基于时间聚合的,建议都使用 datemath 方式做缓存处理以优化性能。
例如,由于 “now” 值一直在变化,因此无法缓存在过滤器上下文中使用 “now” 的查询。那怎么使用缓存呢?通过在 now 字段上应用 datemath 格式将其四舍五入到最接近的分钟/小时等,可以使此类请求更具可缓存性,以便可以对筛选结果进行缓存。
使用节点查询缓存
聚合语句中,设置:size:0,就会使用分片请求缓存缓存结果。size = 0 的含义是:只返回聚合结果,不返回查询结果。GET /es_db/_search{ \"size\
使用分片请求缓存
#常规的多条件聚合实现GET /employees/_search{ \"size\
Elasticsearch 查询条件中同时有多个条件聚合,默认情况下聚合不是并行运行的。当为每个聚合提供自己的查询并执行 msearch 时,性能会有显著提升。因此,在 CPU 资源不是瓶颈的前提下,如果想缩短响应时间,可以将多个聚合拆分为多个查询,借助:msearch 实现并行聚合。
拆分聚合,使聚合并行化
Elasticsearch 聚合性能优化
搜索技术深入
分布式系统的可用性与扩展性高可用性 服务可用性-允许有节点停止服务 数据可用性-部分节点丢失,不会丢失数据可扩展性 请求量提升/数据的不断增长(将数据分布到所有节点上)
ES集群架构的优势:提高系统的可用性,部分节点停止服务,整个集群的服务不受影响存储的水平扩容
集群一个集群可以有一个或者多个节点不同的集群通过不同的名字来区分,默认名字“elasticsearch“通过配置文件修改,或者在命令行中 -E cluster.name=es-cluster进行设定
节点类型Master Node:主节点Master eligible nodes:可以参与选举的合格节点Data Node:数据节点Coordinating Node:协调节点其他节点
Master Node的职责处理创建,删除索引等请求,负责索引的创建与删除决定分片被分配到哪个节点维护并且更新Cluster State
Master Node的最佳实践Master节点非常重要,在部署上需要考虑解决单点的问题为一个集群设置多个Master节点,每个节点只承担Master 的单一角色
选主的过程互相Ping对方,Node ld 低的会成为被选举的节点其他节点会加入集群,但是不承担Master节点的角色。一旦发现被选中的主节点丢失,就会选举出新的Master节点
Data Node可以保存数据的节点,叫做Data Node,负责保存分片数据。在数据扩展上起到了至关重要的作用节点启动后,默认就是数据节点。可以设置node.data: false 禁止由Master Node决定如何把分片分发到数据节点上通过增加数据节点可以解决数据水平扩展和解决数据单点问题
Coordinating Node负责接受Client的请求, 将请求分发到合适的节点,最终把结果汇集到一起每个节点默认都起到了Coordinating Node的职责
Data Node & Coordinating Node
节点节点是一个Elasticsearch的实例 本质上就是一个JAVA进程 一台机器上可以运行多个Elasticsearch进程,但是生产环境一般建议一台机器上只运行一个Elasticsearch实例每一个节点都有名字,通过配置文件配置,或者启动时候 -E node.name=node1指定每一个节点在启动之后,会分配一个UID,保存在data目录下
主分片(Primary Shard)用以解决数据水平扩展的问题。通过主分片,可以将数据分布到集群内的所有节点之上一个分片是一个运行的Lucene的实例主分片数在索引创建时指定,后续不允许修改,除非Reindex
副本分片(Replica Shard)用以解决数据高可用的问题。 副本分片是主分片的拷贝副本分片数,可以动态调整增加副本数,还可以在一定程度上提高服务的可用性(读取的吞吐)
blogs对应的架构思考:增加一个节点或改大主分片数对系统有什么影响?
# 指定索引的主分片和副本分片数PUT /blogs{ \"settings\": { \"number_of_shards\
对于生产环境中分片的设定,需要提前做好容量规划分片数设置过小 导致后续无法增加节点实现水平扩展 单个分片的数据量太大,导致数据重新分配耗时分片数设置过大,7.0 开始,默认主分片设置成1,解决了over-sharding(分片过度)的问题 影响搜索结果的相关性打分,影响统计结果的准确性 单个节点上过多的分片,会导致资源浪费,同时也会影响性能
#查看集群的健康状况GET _cluster/health
分片的设定
集群status
GET /_cat/nodes?v #查看节点信息GET /_cat/health?v #查看集群当前状态:红、黄、绿GET /_cat/shards?v #查看各shard的详细情况 GET /_cat/shards/{index}?v #查看指定分片的详细情况GET /_cat/master?v #查看master节点信息GET /_cat/indices?v #查看集群中所有index的详细信息GET /_cat/indices/{index}?v #查看集群中指定index的详细信息
CAT API查看集群信息:
分片(Primary Shard & Replica Shard)
核心概念
操作系统: CentOS7,准备用户eselasticsearch:elasticsearch-7.17.3切换到root用户,修改/etc/hostsvim /etc/hosts192.168.65.174 es-node1 192.168.65.192 es-node2 192.168.65.204 es-node3
系统环境
#192.168.65.174的配置cluster.name: es-clusternode.name: node-1node.master: truenode.data: truenetwork.host: 0.0.0.0discovery.seed_hosts: [\"es-node1\
三个节点配置如下:
验证集群http://192.168.65.174:9200/_cat/nodes?pretty
修改elasticsearch.yml
Cerebro介绍Cerebro 可以查看分片分配和通过图形界面执行常见的索引操作。 完全开源,并且它允许添加用户,密码或 LDAP 身份验证问网络界面。Cerebro 基于 Scala 的Play 框架编写,用于后端 REST 和 Elasticsearch 通信。 它使用通过 AngularJS 编写的单页应用程序(SPA)前端。
安装Cerebro客户端
修改kibana配置vim config/kibana.ymlserver.port: 5601server.host: \"192.168.65.174\" elasticsearch.hosts: [\"http://192.168.65.174:9200\
安装kibana
提示:Kibana对外的 tcp 端口是5601,使用netstat -tunlp|grep 5601即可查看进程#后台启动nohup bin/kibana &访问Kibana: http://192.168.65.174:5601/
运行Kibana
搭建三节点ES集群
ES安全认证参考文档:https://www.elastic.co/guide/en/elasticsearch/reference/7.17/configuring-stack-security.html
免费的方案设置nginx反向代理安装免费的Security插件Search Guard : https://search-guard.com/readonlyrest: https://readonlyrest.com/X-Pack的Basic版从ES 6.8开始,Security纳入x-pack的Basic版本中,免费使用一些基本的功能
ElasticSearch集群内部的数据是通过9300进行传输的,如果不对数据加密,可能会造成数据被抓包,敏感信息泄露。解决方案: 为节点创建证书TLS 协议要求Trusted Certificate Authority (CA)签发x.509的证书。证书认证的不同级别:Certificate ——节点加入需要使用相同CA签发的证书Full Verification——节点加入集群需要相同CA签发的证书,还需要验证Host name 或IP地址No Verification——任何节点都可以加入,开发环境中用于诊断目的
1)生成节点证书# 为集群创建一个证书颁发机构bin/elasticsearch-certutil ca# 为集群中的每个节点生成证书和私钥bin/elasticsearch-certutil cert --ca elastic-stack-ca.p12# 移动到config目录下mv *.p12 config/将如上命令生成的两个证书文件拷贝到另外两个节点作为通信依据。# 拷贝到192.168.65.192scp *.p12 es@192.168.65.192:/home/es/elasticsearch-7.17.3/config
2)配置节点间通信三个ES节点增加如下配置:## elasticsearch.yml 配置xpack.security.transport.ssl.enabled: truexpack.security.transport.ssl.verification_mode: certificate xpack.security.transport.ssl.client_authentication: requiredxpack.security.transport.ssl.keystore.path: elastic-certificates.p12xpack.security.transport.ssl.truststore.path: elastic-certificates.p12
集群内部安全通信
1)修改elasticsearch.yml配置文件,开启xpack认证机制xpack.security.enabled: true # 开启xpack认证机制测试:#使用Curl访问ES,返回401错误curl 'localhost:9200/_cat/nodes?pretty'浏览器访问http://192.168.65.174:9200/需要输入用户名密码
测试curl -u elastic 'localhost:9200/_cat/nodes?pretty'
修改kibana.ymlelasticsearch.username: \"kibana_system\"elasticsearch.password: \"123456\"启动kibana服务nohup bin/kibana &
3)配置Kibana开启了安全认证之后,kibana连接es以及访问es都需要认证。
修改配置文件vim conf/application.confhosts = [ { host = \"http://192.168.65.174:9200\" name = \"es-cluster\" auth = { username = \"elastic\" password = \"123456\" } }]
启动cerebro服务nohup bin/cerebro > cerebro.log &
4)配置cerebro
开启并配置X-Pack的认证
ES集群架构
一个节点只承担一个角色的配置#Master节点node.master: truenode.ingest: falsenode.data: false#data节点node.master: falsenode.ingest: falsenode.data: true#ingest 节点node.master: falsenode.ingest: truenode.data: false#coordinate节点node.master: falsenode.ingest: falsenode.data: false
生产环境中,建议为一些大的集群配置Coordinating Only Nodes 扮演Load Balancers,降低Master和 Data Nodes的负载 负责搜索结果的Gather/Reduce 有时候无法预知客户端会发送怎么样的请求。比如大量占用内存的操作,一个深度聚合可能会引发OOM
不同角色的节点:Master eligible / Data / Ingest / Coordinating /Machine Learning在开发环境中,一个节点可承担多种角色。在生产环境中: 根据数据量,写入和查询的吞吐量,选择合适的部署方式 建议设置单一角色的节点
从高可用&避免脑裂的角度出发:一般在生产环境中配置3台一个集群只有1台活跃的主节点(master node) 负责分片管理,索引创建,集群管理等操作如果和数据节点或者Coordinate节点混合部署 数据节点相对有比较大的内存占用 Coordinate节点有时候可能会有开销很高的查询,导致OOM 这些都有可能影响Master节点,导致集群的不稳定
单一 master eligible nodes
当磁盘容量无法满足需求时,可以增加数据节点;磁盘读写压力大时,增加数据节点当系统中有大量的复杂查询及聚合时候,增加Coordinating节点,增加查询的性能
增加节点水平扩展场景
读写分离架构
全局流量管理(GTM)和负载均衡(SLB)的区别: GTM 是通过DNS将域名解析到多个IP地址,不同用户访问不同的IP地址,来实现应用服务流量的分配。同时通过健康检查动态更新DNS解析IP列表,实现故障隔离以及故障切换。最终用户的访问直接连接服务的IP地址,并不通过GTM。而 SLB 是通过代理用户访问请求的形式将用户访问请求实时分发到不同的服务器,最终用户的访问流量必须要经过SLB。 一般来说,相同Region使用SLB进行负载均衡,不同region的多个SLB地址时,则可以使用GTM进行负载均衡。
ES 跨集群复制 (Cross-Cluster Replication)是ES 6.7的的一个全局高可用特性。CCR允许不同的索引复制到一个或多个ES 集群中。https://www.elastic.co/guide/en/elasticsearch/reference/7.17/ccr-apis.html
异地多活架构集群处在三个数据中心,数据三写,GTM分发读请求
ES数据通常不会有 Update操作;适用于Time based索引数据,同时数据量比较大的场景。引入 Warm节点,低配置大容量的机器存放老数据,以降低部署成本两类数据节点,不同的硬件配置: Hot节点(通常使用SSD)︰索引不断有新文档写入。 Warm 节点(通常使用HDD)︰索引不存在新数据的写入,同时也不存在大量的数据查询
为什么要设计Hot & Warm 架构?
用于数据的写入:lndexing 对 CPU和IO都有很高的要求,所以需要使用高配置的机器存储的性能要好,建议使用SSD
Hot Nodes
用于保存只读的索引,比较旧的数据。通常使用大容量的磁盘
Warm Nodes
使用Shard Filtering实现Hot&Warm node间的数据迁移
设置 分配索引到节点,节点的属性规则index.routing.allocation.include.{attr} 至少包含一个值index.routina.allocation.exclude.{attr} 不能包含任何一个值index.routina.allocation.require. {attr} 所有值都需要包含
node.attr来指定node属性:hot或是warm。在index的settings里通过index.routing.allocation来指定索引(index)到一个满足要求的node
1) 标记节点需要通过“node.attr”来标记一个节点节点的attribute可以是任何的key/value可以通过elasticsearch.yml 或者通过-E命令指定# 标记一个 Hot 节点elasticsearch.bat -E node.name=hotnode -E cluster.name=tulingESCluster -E http.port=9200 -E path.data=hot_data -E node.attr.my_node_type=hot# 标记一个 warm 节点elasticsearch.bat -E node.name=warmnode -E cluster.name=tulingESCluster -E http.port=9201 -E path.data=warm_data -E node.attr.my_node_type=warm# 查看节点GET /_cat/nodeattrs?v
创建索引时候,指定将其创建在hot节点上# 配置到 Hot节点PUT /index-2022-05{ \"settings\":{ \"number_of_shards\
2)配置Hot数据
使用 Shard Filtering,步骤分为以下几步:标记节点(Tagging)配置索引到Hot Node配置索引到 Warm节点
配置Hot & Warm 架构
Hot & Warm 架构
机器的软硬件配置单条文档的大小│文档的总数据量│索引的总数据量((Time base数据保留的时间)|副本分片数文档是如何写入的(Bulk的大小)文档的复杂度,文档是如何进行读取的(怎么样的查询和聚合)
评估业务的性能需求: 数据吞吐及性能需求 数据写入的吞吐量,每秒要求写入多少数据? 查询的吞吐量? 单条查询可接受的最大返回时间?了解你的数据 数据的格式和数据的Mapping 实际的查询和聚合长的是什么样的
ES集群常见应用场景: 搜索: 固定大小的数据集 搜索的数据集增长相对比较缓慢日志: 基于时间序列的数据 使用ES存放日志与性能指标。数据每天不断写入,增长速度较快 结合Warm Node 做数据的老化处理
硬件配置:选择合理的硬件,数据节点尽可能使用SSD搜索等性能要求高的场景,建议SSD按照1∶10的比例配置内存和硬盘日志类和查询并发低的场景,可以考虑使用机械硬盘存储按照1:50的比例配置内存和硬盘单节点数据建议控制在2TB以内,最大不建议超过5TBJVM配置机器内存的一半,JVM内存配置不建议超过32G不建议在一台服务器上运行多个节点
内存大小要根据Node 需要存储的数据来进行估算搜索类的比例建议: 1:16日志类: 1:48——1:96之间假设总数据量1T,设置一个副本就是2T总数据量如果搜索类的项目,每个节点31*16 = 496 G,加上预留空间。所以每个节点最多400G数据,至少需要5个数据节点如果是日志类项目,每个节点31*50= 1550 GB,2个数据节点即可
部署方式:按需选择合理的部署方式如果需要考虑可靠性高可用,建议部署3台单一的Master节点如果有复杂的查询和聚合,建议设置Coordinating节点集群扩容:
集群扩容:增加Coordinating / Ingest Node解决CPU和内存开销的问题增加数据节点解决存储的容量的问题为避免分片分布不均的问题,要提前监控磁盘空间,提前清理数据或增加节点
一个集群总共需要多少个节点?一个索引需要设置几个分片?规划上需要保持一定的余量,当负载出现波动,节点出现丢失时,还能正常运行。做容量规划时,一些需要考虑的因素:
特性: 被搜索的数据集很大,但是增长相对比较慢(不会有大量的写入)。更关心搜索和聚合的读取性能数据的重要性与时间范围无关。关注的是搜索的相关度
估算索引的的数据量,然后确定分片的大小:单个分片的数据不要超过20 GB可以通过增加副本分片,提高查询的吞吐量
思考:如果单个索引数据量非常大,如何优化提升查询性能?拆分索引如果业务上有大量的查询是基于一个字段进行Filter,该字段又是一个数量有限的枚举值。例如订单所在的地区。可以考虑以地区进行索引拆分
es分片路由的规则:shard_num = hash(_routing) % num_primary_shards_routing字段的取值,默认是_id字段,可以自定义。PUT /users{ \"settings\": { \"number_of_shards\":2 }}POST /users/_create/1?routing=fox{ \"name\":\"fox\"}
如果在单个索引有大量的数据,可以考虑将索引拆分成多个索引:查询性能可以得到提高如果要对多个索引进行查询,还是可以在查询中指定多个索引得以实现如果业务上有大量的查询是基于一个字段进行Filter,该字段数值并不固定可以启用Routing 功能,按照filter 字段的值分布到集群中不同的shard,降低查询时相关的shard数提高CPU利用率
容量规划案例1: 产品信息库搜索
相关场景:日志/指标/安全相关的事件舆情分析特性:每条数据都有时间戳,文档基本不会被更新(日志和指标数据)用户更多的会查询近期的数据,对旧的数据查询相对较少对数据的写入性能要求比较高创建基于时间序列的索引:在索引的名字中增加时间信息按照每天/每周/每月的方式进行划分这样做的好处:更加合理的组织索引,例如随着时间推移,便于对索引做的老化处理。可以利用Hot & Warm 架构备份和删除以及删除的效率高。(Delete By Query执行速度慢,底层也不会立刻释放空间)
# PUT /<logs-{now/d}PUT /%3Clogs-%7Bnow%2Fd%7D%3E# POST /<logs-{now/d}>/_searchPOST /%3Clogs-%7Bnow%2Fd%7D%3E/_search
基于Date Math方式建立索引比如:假设当前日期 2022-05-27 <indexName-{now/d}> indexName-2022.05.27<indexName-{now{YYYY.MM}}> indexName-2022.05
PUT /logs_2022-05-27PUT /logs_2022-05-26#可以每天晚上定时执行POST /_aliases{ \"actions\": [ { \"add\": { \"index\": \"logs_2022-05-27\
基于Index Alias索引最新的数据
容量规划案例2: 基于时间序列的数据
如何对集群的容量进行规划
生产环境常见集群部署方式
单集群水平扩展时,节点数不能无限增加 当集群的meta 信息(节点,索引,集群状态)过多会导致更新压力变大,单个Active Master会成为性能瓶颈,导致整个集群无法正常工作早期版本,通过Tribe Node可以实现多集群访问的需求,但是还存在一定的问题 Tribe Node会以Client Node的方式加入每个集群,集群中Master节点的任务变更需要Tribe Node 的回应才能继续。 Tribe Node 不保存Cluster State信息,一旦重启,初始化很慢 当多个集群存在索引重名的情况时,只能设置一种 Prefer 规则
ES水平扩展存在的问题
早期Tribe Node 的方案存在一定的问题,现已被弃用。Elasticsearch 5.3引入了跨集群搜索的功能(Cross Cluster Search),推荐使用 允许任何节点扮演联合节点,以轻量的方式,将搜索请求进行代理 不需要以Client Node的形式加入其他集群
//启动3个集群elasticsearch.bat -E node.name=cluster0node -E cluster.name=cluster0 -E path.data=cluster0_data -E discovery.type=single-node -E http.port=9200 -E transport.port=9300elasticsearch.bat -E node.name=cluster1node -E cluster.name=cluster1 -E path.data=cluster1_data -E discovery.type=single-node -E http.port=9201 -E transport.port=9301elasticsearch.bat -E node.name=cluster2node -E cluster.name=cluster2 -E path.data=cluster2_data -E discovery.type=single-node -E http.port=9202 -E transport.port=9302//在每个集群上设置动态的设置PUT _cluster/settings{ \"persistent\": { \"cluster\": { \"remote\": { \"cluster0\": { \"seeds\": [ \"127.0.0.1:9300\
配置集群
1)seeds配置的远程集群的remote cluster的一个node。2)connected如果至有少一个到远程集群的连接则为true。3)num_nodes_connected远程集群中连接节点的数量。4)max_connections_per_cluster远程集群维护的最大连接数。5)transport.ping_schedule设置了tcp层面的活性监听6)skip_unavailable设置为true的话,当这个remote cluster不可用的时候,就会忽略,默认是false,当对应的remote cluster不可用的话,则会报错。7)cluster.remote.connections_per_clustergateway nodes数量,默认是38)cluster.remote.initial_connect_timeout节点启动时等待远程节点的超时时间,默认是30s9)cluster.remote.node.attr:一个节点属性,用于过滤掉remote cluster中 符合gateway nodes的节点,比如设置cluster.remote.node.attr=gateway,那么将匹配节点属性node.attr.gateway: true 的node才会被该node连接用来做CCS查询。10)cluster.remote.connect:默认情况下,群集中的任意节点都可以充当federated client并连接到remote cluster,cluster.remote.connect可以设置为 false(默认为true)以防止某些节点连接到remote cluster11)在使用api进行动态设置的时候每次都要把seeds带上
CCS的配置:
#在不同集群上执行# cluster0 localhost:9200POST /users/_doc{ \"name\":\"fox\
创建测试数据
查询
跨集群搜索实战
ES跨集群搜索(CCS)
单个分片7.0开始,新创建一个索引时,默认只有一个主分片。单个分片,查询算分,聚合不准的问题都可以得以避免单个索引,单个分片时候,集群无法实现水平扩展。即使增加新的节点,无法实现水平扩展
集群增加一个节点后,Elasticsearch 会自动进行分片的移动,也叫 Shard Rebalancing
两个分片
相关性算分在分片之间是相互独立的,每个分片都基于自己的分片上的数据进行相关度计算。这会导致打分偏离的情况,特别是数据量很少时。当文档总数很少的情况下,如果主分片大于1,主分片数越多,相关性算分会越不准
PUT /blogs{ \"settings\":{ \"number_of_shards\" : \"3\" }}POST /blogs/_doc/1?routing=fox{ \"content\":\"Cross Cluster elasticsearch Search\"}POST /blogs/_doc/2?routing=fox2{ \"content\":\"elasticsearch Search\"}POST /blogs/_doc/3?routing=fox3{ \"content\":\"elasticsearch\"}GET /blogs/_search{ \"query\": { \"match\": { \"content\": \"elasticsearch\" } }}#解决算分不准的问题GET /blogs/_search?search_type=dfs_query_then_fetch{ \"query\": { \"match\": { \"content\": \"elasticsearch\" } }}
Demo
解决算分不准的方法: 数据量不大的时候,可以将主分片数设置为1。当数据量足够大时候,只要保证文档均匀分散在各个分片上,结果一般就不会出现偏差 使用DFS Query Then Fetch 搜索的URL中指定参数“_search?search_type=dfs_query_then_fetch\
算分不准的原因
当分片数>节点数时一旦集群中有新的数据节点加入,分片就可以自动进行分配分片在重新分配时,系统不会有downtime
多分片的好处: 一个索引如果分布在不同的节点,多个节点可以并行执行查询可以并行执行数据写入可以分散到多个机器
案例1每天1GB的数据,一个索引一个主分片,一个副本分片需保留半年的数据,接近360 GB的数据量,360个分片
案例25个不同的日志,每天创建一个日志索引。每个日志索引创建10个主分片保留半年的数据5*10* 30* 6 = 9000个分片
分片过多所带来的副作用Shard是Elasticsearch 实现集群水平扩展的最小单位。过多设置分片数会带来一些潜在的问题:
从存储的物理角度看:搜索类应用,单个分片不要超过20 GB日志类应用,单个分片不要大于50 GB
为什么要控制分片存储大小:提高Update 的性能进行Merge 时,减少所需的资源丢失节点后,具备更快的恢复速度便于分片在集群内 Rebalancing
如何确定主分片数
副本是主分片的拷贝:提高系统可用性︰响应查询请求,防止数据丢失需要占用和主分片一样的资源
对性能的影响:副本会降低数据的索引速度: 有几份副本就会有几倍的CPU资源消耗在索引上会减缓对主分片的查询压力,但是会消耗同样的内存资源。如果机器资源充分,提高副本数,可以提高整体的查询QPS
ES的分片策略会尽量保证节点上的分片数大致相同,但是有些场景下会导致分配不均匀:扩容的新节点没有数据,导致新索引集中在新的节点热点数据过于集中,可能会产生性能问题
可以通过调整分片总数,避免分配不均衡\"index.routing.allocation.total_shards_per_node\",index级别的,表示这个index每个Node总共允许存在多少个shard,默认值是-1表示无穷多个;\"cluster.routing.allocation.total_shards_per_node\",cluster级别,表示集群范围内每个Node允许存在有多少个shard。默认值是-1表示无穷多个。 如果目标Node的Shard数超过了配置的上限,则不允许分配Shard到该Node上。注意:index级别的配置会覆盖cluster级别的配置。
思考:5个节点的集群。索引有5个主分片,1个副本,index.routing.allocation.total_shards_per_node应该如何设置?(5+5)/ 5= 2生产环境中要适当调大这个数字,避免有节点下线时,分片无法正常迁移
如何确定副本分片数
如何设计分片数
分片的设计和管理
写请求是写入 primary shard,然后同步给所有的 replica shard;读请求可以从 primary shard 或 replica shard 读取,采用的是随机轮询算法。
客户端选择一个node发送请求过去,这个node就是coordinating node (协调节点)coordinating node,对document进行路由,将请求转发给对应的nodenode上的primary shard处理请求,然后将数据同步到replica nodecoordinating node如果发现primary node和所有的replica node都搞定之后,就会返回请求到客户端
ES写入数据的过程
根据 doc id 进行 hash,判断出来当时把 doc id 分配到了哪个 shard 上面去,从那个 shard 去查询。客户端发送请求到任意一个 node,成为 coordinate node 。coordinate node 对 doc id 进行哈希路由,将请求转发到对应的 node,此时会使用 round-robin 随机轮询算法,在 primary shard 以及其所有 replica 中随机选择一个,让读请求负载均衡。接收请求的 node 返回 document 给 coordinate node 。coordinate node 返回 document 给客户端。
根据id查询数据的过程
客户端发送请求到一个 coordinate node 。协调节点将搜索请求转发到所有的 shard 对应的 primary shard 或 replica shard ,都可以。query phase:每个 shard 将自己的搜索结果返回给协调节点,由协调节点进行数据的合并、排序、分页等操作,产出最终结果。fetch phase:接着由协调节点根据 doc id 去各个节点上拉取实际的 document 数据,最终返回给客户端。
根据关键词查询数据的过程
ES读取数据的过程
segment file: 存储倒排索引的文件,每个segment本质上就是一个倒排索引,每秒都会生成一个segment文件,当文件过多时es会自动进行segment merge(合并文件),合并时会同时将已经标注删除的文档物理删除。
commit point: 记录当前所有可用的segment,每个commit point都会维护一个.del文件,即每个.del文件都有一个commit point文件(es删除数据本质是不属于物理删除),当es做删改操作时首先会在.del文件中声明某个document已经被删除,文件内记录了在某个segment内某个文档已经被删除,当查询请求过来时在segment中被删除的文件是能够查出来的,但是当返回结果时会根据commit point维护的那个.del文件把已经删除的文档过滤掉
translog日志文件: 为了防止elasticsearch宕机造成数据丢失保证可靠存储,es会将每次写入数据同时写到translog日志中。
os cache:操作系统里面,磁盘文件其实都有一个东西,叫做os cache,操作系统缓存,就是说数据写入磁盘文件之前,会先进入os cache,先进入操作系统级别的一个内存缓存中去
TranslogSegment没有写入磁盘,即便发生了当机,重启后,数据也能恢复,从ES6.0开始默认配置是每次请求都会落盘
Flush删除旧的translog 文件生成Segment并写入磁盘│更新commit point并写入磁盘。ES自动完成,可优化点不多
写数据底层原理
ES底层读写工作原理
#避免查询时脚本GET blogs/_search{ \"query\": { \"bool\": { \"must\": [ {\"match\": { \"title\": \"elasticsearch\
尽量将数据先行计算,然后保存到Elasticsearch 中。尽量避免查询时的 Script计算
尽量使用Filter Context,利用缓存机制,减少不必要的算分结合profile,explain API分析慢查询的问题,持续优化数据模型
GET /es_db/_search{ \"query\": { \"wildcard\": { \"address\": { \"value\": \"*白云*\" } } }}
避免使用*开头的通配符查询
数据建模
避免Over Sharing一个查询需要访问每一个分片,分片过多,会导致不必要的查询开销
结合应用场景,控制单个分片的大小Search: 20GBLogging: 40GB
Force-merge Read-only索引使用基于时间序列的索引,将只读的索引进行force merge,减少segment数量#手动force mergePOST /my_index/_forcemerge
优化分片
提升集群读取性能的方法
写性能优化的目标: 增大写吞吐量,越高越好客户端: 多线程,批量写 可以通过性能测试,确定最佳文档数量 多线程: 需要观察是否有HTTP 429(Too Many Requests)返回,实现 Retry以及线程数量的自动调节span style=\"font-size:inherit;\
降低IO操作 使用ES自动生成的文档ld 一些相关的ES 配置,如Refresh Interval降低 CPU 和存储开销 减少不必要分词 避免不需要旳doc_values 文档的字段尽量保证相同的顺予,可以提高文档的压缩率尽可能做到写入和分片的均衡负载,实现水平扩展 Shard Filtering / Write Load Balancer调整Bulk 线程池和队列注意:ES 的默认设置,已经综合考虑了数据可靠性,搜索的实时性,写入速度,一般不要盲目修改。一切优化,都要基于高质量的数据建模。
服务器端优化写入性能的一些手段
只需要聚合不需要搜索,index设置成false不要对字符串使用默认的dynamic mapping。字段数量过多,会对性能产生比较大的影响Index_options控制在创建倒排索引时,哪些内容会被添加到倒排索引中。
如果需要追求极致的写入速度,可以牺牲数据可靠性及搜索实时性以换取性能:牺牲可靠性: 将副本分片设置为0,写入完毕再调整回去牺牲搜索实时性︰增加Refresh Interval的时间牺牲可靠性: 修改Translog的配置
建模时的优化
PUT /my_index/_settings{ \"index\" : { \"refresh_interval\" : \"10s\" }}
增加refresh_interval 的数值。默认为1s ,如果设置成-1,会禁止自动refresh 避免过于频繁的refresh,而生成过多的segment 文件 但是会降低搜索的实时性
增大静态配置参数indices.memory.index_buffer_size默认是10%,会导致自动触发refresh
降低 Refresh的频率
Index.translog.durability: 默认是request,每个请求都落盘。设置成async,异步写入lndex.translog.sync_interval:设置为60s,每分钟执行一次Index.translog.flush_threshod_size: 默认512 m,可以适当调大。当translog 超过该值,会触发flush
降低Translog写磁盘的频率,但是会降低容灾能力
副本在写入时设为0,完成后再增加合理设置主分片数,确保均匀分配在所有数据节点上Index.routing.allocation.total_share_per_node:限定每个索引在每个节点上可分配的主分片数
分片设定
客户端单个bulk请求体的数据量不要太大,官方建议大约5-15m写入端的 bulk请求超时需要足够长,建议60s 以上写入端尽量将数据轮询打到不同节点。
服务器端索引创建属于计算密集型任务,应该使用固定大小的线程池来配置。来不及处理的放入队列,线程数应该配置成CPU核心数+1,避免过多的上下文切换队列大小可以适当增加,不要过大,否则占用的内存会成为GC的负担ES线程池设置: https://blog.csdn.net/justlpf/article/details/103233215
DELETE myindexPUT myindex{ \"settings\": { \"index\": { \"refresh_interval\": \"30s\
调整Bulk 线程池和队列
提升写入性能的方法
如何提升集群的读写性能
集群架构和原理
关系型数据库范式化(Normalize)设计的主要目标是减少不必要的更新,往往会带来一些副作用:一个完全范式化设计的数据库会经常面临“查询缓慢”的问题。数据库越范式化,就需要Join越多的表;范式化节省了存储空间,但是存储空间已经变得越来越便宜;范式化简化了更新,但是数据读取操作可能更多。
反范式化(Denormalize)的设计不使用关联关系,而是在文档中保存冗余的数据拷贝。优点: 无需处理Join操作,数据读取性能好。Elasticsearch可以通过压缩_source字段,减少磁盘空间的开销缺点: 不适合在数据频繁修改的场景。 一条数据的改动,可能会引起很多数据的更新
关系型数据库,一般会考虑Normalize 数据;在Elasticsearch,往往考虑Denormalize 数据。Elasticsearch并不擅长处理关联关系,一般会采用以下四种方法处理关联:对象类型嵌套对象(Nested Object)父子关联关系(Parent / Child )应用端关联
对象类型:在每一博客的文档中都保留作者的信息如果作者信息发生变化,需要修改相关的博客文档
DELETE blog# 设置blog的 MappingPUT /blog{ \"mappings\": { \"properties\": { \"content\": { \"type\": \"text\
案例1: 博客作者信息变更
DELETE /my_movies# 电影的Mapping信息PUT /my_movies{ \"mappings\" : { \"properties\" : { \"actors\" : { \"properties\" : { \"first_name\" : { \"type\" : \"keyword\
案例2:包含对象数组的文档
对象类型
DELETE /my_movies# 创建 Nested 对象 MappingPUT /my_movies{ \"mappings\" : { \"properties\" : { \"actors\" : { \"type\": \"nested\
DELETE /my_blogs# 设定 Parent/Child MappingPUT /my_blogs{ \"settings\": { \"number_of_shards\
#索引父文档PUT /my_blogs/_doc/blog1{ \"title\":\"Learning Elasticsearch\
索引父文档
#索引子文档PUT /my_blogs/_doc/comment1?routing=blog1{ \"comment\":\"I am learning ELK\
注意:父文档和子文档必须存在相同的分片上,能够确保查询join 的性能当指定子文档时候,必须指定它的父文档ld。使用routing参数来保证,分配到相同的分片
索引子文档
# 查询所有文档POST /my_blogs/_search#根据父文档ID查看GET /my_blogs/_doc/blog2# Parent Id 查询POST /my_blogs/_search{ \"query\": { \"parent_id\": { \"type\": \"comment\
父子关联关系(Parent / Child )对象和Nested对象的局限性: 每次更新,可能需要重新索引整个对象(包括根对象和嵌套对象)ES提供了类似关系型数据库中Join 的实现。使用Join数据类型实现,可以通过维护Parent/ Child的关系,从而分离两个对象 父文档和子文档是两个独立的文档 更新父文档无需重新索引子文档。子文档被添加,更新或者删除也不会影响到父文档和其他的子文档设定 Parent/Child Mapping
Nested ObjectParent / Child优点 文档存储在一起,读取性能高 父子文档可以独立更新缺点 更新嵌套的子文档时,需要更新整个文档 需要额外的内存维护关系。读取性能 相对差适用场景子文档偶尔更新,以查询为主 子文档更新频繁
嵌套文档 VS 父子文档
嵌套对象(Nested Object)
应用场景: 修复与增强写入数据
需求:Tags字段中,逗号分隔的文本应该是数组,而不是一个字符串。后期需要对Tags进行Aggregation统计#Blog数据,包含3个字段,tags用逗号间隔PUT tech_blogs/_doc/1{ \"title\":\"Introducing big data......\
案例
Elasticsearch 5.0后,引入的一种新的节点类型。默认配置下,每个节点都是Ingest Node:具有预处理数据的能力,可拦截lndex或 Bulk API的请求对数据进行转换,并重新返回给Index或 Bulk APl无需Logstash,就可以进行数据的预处理,例如:为某个字段设置默认值;重命名某个字段的字段名;对字段值进行Split 操作支持设置Painless脚本,对数据进行更加复杂的加工
Ingest Node
Pipeline ——管道会对通过的数据(文档),按照顺序进行加工Processor——Elasticsearch 对一些加工的行为进行了抽象包装Elasticsearch 有很多内置的Processors,也支持通过插件的方式,实现自己的Processor
# 测试split tagsPOST _ingest/pipeline/_simulate{ \"pipeline\": { \"description\": \"to split blog tags\
# 为ES添加一个 PipelinePUT _ingest/pipeline/blog_pipeline{ \"description\": \"a blog pipeline\
创建pipeline
#不使用pipeline更新数据PUT tech_blogs/_doc/1{ \"title\":\"Introducing big data......\
使用pipeline更新数据
#update_by_query 会导致错误POST tech_blogs/_update_by_query?pipeline=blog_pipeline{}#增加update_by_query的条件POST tech_blogs/_update_by_query?pipeline=blog_pipeline{ \"query\": { \"bool\": { \"must_not\": { \"exists\": { \"field\": \"views\" } } } }}GET tech_blogs/_search
借助update_by_query更新已存在的文档
Pipeline & Processor
Logstash Ingest Node数据输入与输出 支持从不同的数据源读取,并写入不同的数据源 支持从ES REST API获取数据,并且写入Elasticsearch数据缓冲 实现了简单的数据队列,支持重写 不支持缓冲数据处理 支持大量的插件,也支持定制开发 内置的插件,可以开发Plugin进行扩展(Plugin更新需要重启)配置和使用 增加了一定的架构复杂度 无需额外部署
Ingest Node VS Logstash
自Elasticsearch 5.x后引入,专门为Elasticsearch 设计,扩展了Java的语法。6.0开始,ES只支持 Painless。Groovy,JavaScript和 Python 都不再支持。Painless支持所有Java 的数据类型及Java API子集。
Painless Script具备以下特性:高性能/安全支持显示类型或者动态定义类型
Painless的用途:可以对文档字段进行加工处理更新或删除字段,处理数据聚合操作Script Field:对返回的字段提前进行计算Function Score:对文档的算分进行处理在lngest Pipeline中执行脚本在Reindex APl,Update By Query时,对数据进行处理
测试# 增加一个 Script PrcessorPOST _ingest/pipeline/_simulate{ \"pipeline\": { \"description\": \"to split blog tags\
通过Painless脚本访问字段上下文 语法Ingestion ctx.field_nameUpdate ctx._source.field_nameSearch & Aggregation doc[\"field_name\"]
Painless
脚本缓存脚本编译的开销较大,Elasticsearch会将脚本编译后缓存在Cache 中Inline scripts和 Stored Scripts都会被缓存默认缓存100个脚本
Ingest Pipeline & Painless Script
Elasticsearch中如何处理关联关系
Object: 优先考虑反范式(Denormalization)Nested: 当数据包含多数值对象,同时有查询需求Child/Parent:关联文档更新非常频繁时
建模建议1:如何处理关联关系
一个文档中,最好避免大量的字段 过多的字段数不容易维护 Mapping 信息保存在Cluster State 中,数据量过大,对集群性能会有影响 删除或者修改数据需要reindex默认最大字段数是1000,可以设置index.mapping.total_fields.limit限定最大字段数。·
思考:什么原因会导致文档中有成百上千的字段?生产环境中,尽量不要打开 Dynamic,可以使用Strict控制新增字段的加入true :未知字段会被自动加入false :新字段不会被索引,但是会保存在_sourcestrict :新增字段不会被索引,文档写入失败对于多属性的字段,比如cookie,商品属性,可以考虑使用Nested
建模建议2: 避免过多字段
# 将字符串转对象PUT softwares/{ \"mappings\": { \"properties\": { \"version\": { \"properties\": { \"display_name\": { \"type\": \"keyword\
正则,通配符查询,前缀查询属于Term查询,但是性能不够好。特别是将通配符放在开头,会导致性能的灾难案例:针对版本号的搜索
建模建议3︰避免正则,通配符,前缀查询
# Not Null 解决聚合的问题DELETE /scoresPUT /scores{ \"mappings\": { \"properties\": { \"score\": { \"type\": \"float\
建模建议4︰避免空值引起的聚合不准
Mappings设置非常重要,需要从两个维度进行考虑功能︰搜索,聚合,排序性能︰存储的开销; 内存的开销; 搜索的性能
Mappings设置是一个迭代的过程加入新的字段很容易(必要时需要update_by_query)更新删除字段不允许(需要Reindex重建数据)最好能对Mappings 加入Meta 信息,更好的进行版本管理可以考虑将Mapping文件上传git进行管理
PUT /my_index{ \"mappings\": { \"_meta\": { \"index_version_mapping\": \"1.1\" } }}
建模建议5: 为索引的Mapping加入Meta 信息
ElasticSearch数据建模最佳实践
高级功能详解和原理
日志管理的挑战:关注点很多,任何一个点都有可能引起问题日志分散在很多机器,出了问题时,才发现日志被删了很多运维人员是消防员,哪里有问题去哪里
集中化日志管理思路: 日志收集 ——》格式化分析 ——》检索和可视化 ——》 风险告警
背景
ELK架构分为两种,一种是经典的ELK,另外一种是加上消息队列(Redis或Kafka或RabbitMQ)和Nginx结构。
经典的ELK主要是由Filebeat + Logstash + Elasticsearch + Kibana组成,如下图:(早期的ELK只有Logstash + Elasticsearch + Kibana)此架构主要适用于数据量小的开发环境,存在数据丢失的危险。
经典的ELK
这种架构,主要加上了Redis或Kafka或RabbitMQ做消息队列,保证了消息的不丢失。此种架构,主要用在生产环境,可以处理大数据量,并且不会丢失数据。
整合消息队列+Nginx架构
ELK架构
Logstash 是免费且开放的服务器端数据处理管道,能够从多个来源采集数据,转换数据,然后将数据发送到您最喜欢的存储库中。https://www.elastic.co/cn/logstash/应用:ETL工具 / 数据采集处理引擎
包含了input—filter-output三个阶段的处理流程插件生命周期管理队列管理
Pipeline
数据在内部流转时的具体表现形式。数据在input 阶段被转换为Event,在 output被转化成目标格式数据Event 其实是一个Java Object,在配置文件中,对Event 的属性进行增删改查
Logstash Event
将原始数据decode成Event;将Event encode成目标数据
Codec (Code / Decode)
Logstash核心概念
数据采集与输入:Logstash支持各种输入选择,能够以连续的流式传输方式,轻松地从日志、指标、Web应用以及数据存储中采集数据。实时解析和数据转换:通过Logstash过滤器解析各个事件,识别已命名的字段来构建结构,并将它们转换成通用格式,最终将数据从源端传输到存储库中。存储与数据导出:Logstash提供多种输出选择,可以将数据发送到指定的地方。
Logstash通过管道完成数据的采集与处理,管道配置中包含input、output和filter(可选)插件,input和output用来配置输入和输出数据源、filter用来对数据进行过滤或预处理。
Logstash数据传输原理
参考:https://www.elastic.co/guide/en/logstash/7.17/configuration.htmlLogstash的管道配置文件对每种类型的插件都提供了一个单独的配置部分,用于处理管道事件。
input { stdin { }}filter { grok { match => { \"message\" => \"%{COMBINEDAPACHELOG}\" } } date { match => [ \"timestamp\
#运行bin/logstash -f logstash-demo.conf
Logstash配置文件结构
https://www.elastic.co/guide/en/logstash/7.17/input-plugins.html一个 Pipeline可以有多个input插件Stdin / FileBeats / Log4J /Elasticsearch / JDBC / Kafka /Rabbitmq /RedisJMX/ HTTP / Websocket / UDP / TCPGoogle Cloud Storage / S3Github / Twitter
Input Plugins
https://www.elastic.co/guide/en/logstash/7.17/output-plugins.html将Event发送到特定的目的地,是 Pipeline 的最后一个阶段。常见 Output Plugins:ElasticsearchEmail / PagedutyInfluxdb / Kafka / Mongodb / Opentsdb / ZabbixHttp / TCP / Websocket
Output Plugins
https://www.elastic.co/guide/en/logstash/7.17/filter-plugins.html处理Event内置的Filter Plugins:Mutate 一操作Event的字段Metrics — Aggregate metricsRuby 一执行Ruby 代码
Filter Plugins
https://www.elastic.co/guide/en/logstash/7.17/codec-plugins.html将原始数据decode成Event;将Event encode成目标数据内置的Codec Plugins:Line / MultilineJSON / Avro / Cef (ArcSight Common Event Format)Dots / Rubydebug
Codec Plugins
In Memory Queue 进程Crash,机器宕机,都会引起数据的丢失Persistent Queue 机器宕机,数据也不会丢失; 数据保证会被消费; 可以替代 Kafka等消息队列缓冲区的作用
queue.type: persisted (默认是memory)queue.max_bytes: 4gb
Logstash Queue
配置属性
logstash官方文档: https://www.elastic.co/guide/en/logstash/7.17/installing-logstash.html
cd logstash-7.17.3#linux#-e选项表示,直接把配置放在命令中,这样可以有效快速进行测试bin/logstash -e 'input { stdin { } } output { stdout {} }'#windows.\\bin\\logstash.bat -e \"input { stdin { } } output { stdout {} }\"
测试:运行最基本的logstash管道
# single linebin/logstash -e \"input{stdin{codec=>line}}output{stdout{codec=> rubydebug}}\"bin/logstash -e \"input{stdin{codec=>json}}output{stdout{codec=> rubydebug}}\"
设置参数:pattern: 设置行匹配的正则表达式what : 如果匹配成功,那么匹配行属于上一个事件还是下一个事件 previous / nextnegate : 是否对pattern结果取反 true / false
# 多行数据,异常Exception in thread \"main\" java.lang.NullPointerException at com.example.myproject.Book.getTitle(Book.java:16) at com.example.myproject.Author.getBookTitles(Author.java:25) at com.example.myproject.Bootstrap.main(Bootstrap.java:14)# multiline-exception.confinput { stdin { codec => multiline { pattern => \"^\\s\" what => \"previous\" } }}filter {}output { stdout { codec => rubydebug }}#执行管道bin/logstash -f multiline-exception.conf
Codec Plugin —— Multiline
Codec Plugin测试
支持从文件中读取数据,如日志文件文件读取需要解决的问题:只被读取一次。重启后需要从上次读取的位置继续(通过sincedb 实现)读取到文件新内容,发现新文件文件发生归档操作(文档位置发生变化,日志rotation),不能影响当前的内容读取
Input Plugin —— File
Filter Plugin可以对Logstash Event进行各种处理,例如解析,删除字段,类型转换Date: 日期解析Dissect: 分割符解析Grok: 正则匹配解析Mutate: 处理字段。重命名,删除,替换Ruby: 利用Ruby 代码来动态修改Event
对字段做各种操作:Convert : 类型转换Gsub : 字符串替换Split / Join /Merge: 字符串切割,数组合并字符串,数组合并数组Rename: 字段重命名Update / Replace: 字段内容更新替换Remove_field: 字段删除
Filter Plugin - Mutate
Filter Plugin
1)测试数据集下载:https://grouplens.org/datasets/movielens/
input { file { path => \"/home/es/logstash-7.17.3/dataset/movies.csv\" start_position => \"beginning\" sincedb_path => \"/dev/null\" }}filter { csv { separator => \
2)准备logstash-movie.conf配置文件
# linuxbin/logstash -f logstash-movie.conf
3)运行logstash
--config.test_and_exit : 解析配置文件并报告任何错误--config.reload.automatic: 启用自动配置加载
Logstash导入数据到ES
基于canal同步数据(项目实战中讲解)借助JDBC Input Plugin将数据从数据库读到Logstash 需要自己提供所需的 JDBC Driver; JDBC Input Plugin 支持定时任务 Scheduling,其语法来自 Rufus-scheduler,其扩展了 Cron,使用 Cron 的语法可以完成任务的触发; JDBC Input Plugin 支持通过 Tracking_column / sql_last_value 的方式记录 State,最终实现增量的更新; https://www.elastic.co/cn/blog/logstash-jdbc-input-plugin
实现思路
1)拷贝jdbc依赖到logstash-7.17.3/drivers目录下
input { jdbc { jdbc_driver_library => \"/home/es/logstash-7.17.3/drivers/mysql-connector-java-5.1.49.jar\" jdbc_driver_class => \"com.mysql.jdbc.Driver\" jdbc_connection_string => \"jdbc:mysql://localhost:3306/test?useSSL=false\" jdbc_user => \"root\" jdbc_password => \"123456\" #启用追踪,如果为true,则需要指定tracking_column use_column_value => true #指定追踪的字段, tracking_column => \"last_updated\" #追踪字段的类型,目前只有数字(numeric)和时间类型(timestamp),默认是数字类型 tracking_column_type => \"numeric\" #记录最后一次运行的结果 record_last_run => true #上面运行结果的保存位置 last_run_metadata_path => \"jdbc-position.txt\" statement => \"SELECT * FROM user where last_updated >:sql_last_value;\" schedule => \" * * * * * *\" }}output { elasticsearch { document_id => \"%{id}\" document_type => \"_doc\" index => \"users\" hosts => [\"http://localhost:9200\"] user => \"elastic\" password => \"123456\" } stdout{ codec => rubydebug }}
2)准备mysql-demo.conf配置文件
3)运行logstashbin/logstash -f mysql-demo.conf
# 更新update user set address=\"广州白云山\
#ES中查询# 创建 alias,只显示没有被标记 deleted的用户POST /_aliases{ \"actions\": [ { \"add\": { \"index\": \"users\
测试
JDBC Input Plugin实现步骤
同步数据库数据到Elasticsearch
Logstash使用
什么是Logstash
轻量型数据采集器,文档地址: https://www.elastic.co/guide/en/beats/libbeat/7.17/index.htmlBeats 是一个免费且开放的平台,集合了多种单一用途的数据采集器。它们从成百上千或成千上万台机器和系统向 Logstash 或 Elasticsearch 发送数据。
FileBeat简介FileBeat专门用于转发和收集日志数据的轻量级采集工具。它可以作为代理安装在服务器上,FileBeat监视指定路径的日志文件,收集日志数据,并将收集到的日志转发到Elasticsearch或者Logstash。
启动FileBeat时,会启动一个或者多个输入(Input),这些Input监控指定的日志数据位置。FileBeat会针对每一个文件启动一个Harvester(收割机)。Harvester读取每一个文件的日志,将新的日志发送到libbeat,libbeat将数据收集到一起,并将数据发送给输出(Output)。
FileBeat的工作原理
Logstash是在jvm上运行的,资源消耗比较大。而FileBeat是基于golang编写的,功能较少但资源消耗也比较小,更轻量级。Logstash 和Filebeat都具有日志收集功能,Filebeat更轻量,占用资源更少Logstash 具有Filter功能,能过滤分析日志一般结构都是Filebeat采集日志,然后发送到消息队列、Redis、MQ中,然后Logstash去获取,利用Filter功能过滤分析,然后存储到Elasticsearch中FileBeat和Logstash配合,实现背压机制。当将数据发送到Logstash或 Elasticsearch时,Filebeat使用背压敏感协议,以应对更多的数据量。如果Logstash正在忙于处理数据,则会告诉Filebeat 减慢读取速度。一旦拥堵得到解决,Filebeat就会恢复到原来的步伐并继续传输数据。
logstash vs FileBeat
https://www.elastic.co/guide/en/beats/filebeat/7.17/filebeat-installation-configuration.html
output.elasticsearch: hosts: [\"192.168.65.174:9200\
2)编辑配置修改 filebeat.yml 以设置连接信息:
3) 启用和配置数据收集模块从安装目录中,运行:
# setup命令加载Kibana仪表板。 如果仪表板已经设置,则忽略此命令。 ./filebeat setup# 启动Filebeat./filebeat -e
4)启动 Filebeat
Filebeat使用
什么是Beats
vim filebeat-logstash.ymlchmod 644 filebeat-logstash.yml#因为Tomcat的web log日志都是以IP地址开头的,所以我们需要修改下匹配字段。# 不以ip地址开头的行追加到上一行filebeat.inputs:- type: log enabled: true paths: - /home/es/apache-tomcat-8.5.33/logs/*access*.* multiline.pattern: '^\\\\d+\\\\.\\\\d+\\\\.\\\\d+\\\\.\\\\d+ ' multiline.negate: true multiline.match: afteroutput.logstash: enabled: true hosts: [\"192.168.65.204:5044\"]
pattern:正则表达式negate:true 或 false;默认是false,匹配pattern的行合并到上一行;true,不匹配pattern的行合并到上一行match:after 或 before,合并到上一行的末尾或开头
1)创建配置文件filebeat-logstash.yml,配置FileBeats将数据发送到Logstash
2)启动FileBeat,并指定使用指定的配置文件./filebeat -e -c filebeat-logstash.yml
因为安全原因不要其他用户写的权限,去掉写的权限就可以了chmod 644 filebeat-logstash.yml
异常1:Exiting: error loading config file: config file (\"filebeat-logstash.yml\") can only be writable by the owner but the permissions are \"-rw-rw-r--\" (to fix the permissions use: 'chmod go-w /home/es/filebeat-7.17.3-linux-x86_64/filebeat-logstash.yml')
FileBeat将尝试建立与Logstash监听的IP和端口号进行连接。但此时,我们并没有开启并配置Logstash,所以FileBeat是无法连接到Logstash的。
异常2:Failed to connect to backoff(async(tcp://192.168.65.204:5044)): dial tcp 192.168.65.204:5044: connect: connection refused
可能出现的异常:
使用FileBeats将日志发送到Logstash
vim config/filebeat-console.conf# 配置从FileBeat接收数据input { beats { port => 5044 }}output { stdout { codec => rubydebug }}
配置
bin/logstash -f config/filebeat-console.conf --config.test_and_exit
测试logstash配置是否正确
# reload.automatic:修改配置文件时自动重新加载bin/logstash -f config/filebeat-console.conf --config.reload.automatic
启动logstash
配置Logstash接收FileBeat收集的数据并打印
vim config/filebeat-elasticSearch.confinput { beats { port => 5044 }}output { elasticsearch { hosts => [\"http://localhost:9200\"] user => \"elastic\" password => \"123456\" } stdout{ codec => rubydebug }}启动logstash
如果我们需要将数据输出值ES而不是控制台的话,我们修改Logstash的output配置。vim config/filebeat-elasticSearch.conf
启动logstashbin/logstash -f config/filebeat-elasticSearch.conf --config.reload.automaticES中会生成一个以logstash开头的索引,测试日志是否保存到了ES。
思考:日志信息都保证在message字段中,是否可以把日志进行解析一个个的字段?例如:IP字段、时间、请求方式、请求URL、响应结果。
Logstash输出数据到Elasticsearch
查看Logstash已经安装的插件bin/logstash-plugin list
从日志文件中收集到的数据包含了很多有效信息,比如IP、时间等,在Logstash中可以配置过滤器Filter对采集到的数据进行过滤处理,Logstash中有大量的插件可以供我们使用。
Grok是一种将非结构化日志解析为结构化的插件。这个工具非常适合用来解析系统日志、Web服务器日志、MySQL或者是任意其他的日志格式。https://www.elastic.co/guide/en/logstash/7.17/plugins-filters-grok.html
Grok插件
默认在Grok中,所有匹配到的的数据类型都是字符串,如果要转换成int类型(目前只支持int和float),可以这样:%{NUMBER:duration:int} %{IP:client}
grok模式的语法是:%{SYNTAX:SEMANTIC}SYNTAX(语法)指的是Grok模式名称,SEMANTIC(语义)是给模式匹配到的文本字段名。例如:%{NUMBER:duration} %{IP:client}duration表示:匹配一个数字,client表示匹配一个IP地址。
Grok语法
https://help.aliyun.com/document_detail/129387.html?scm=20140722.184.2.173用法
比如,tomacat日志192.168.65.103 - - [23/Jun/2022:22:37:23 +0800] \"GET /docs/images/docs-stylesheet.css HTTP/1.1\" 200 5780
grok模式%{IP:ip} - - \\[%{HTTPDATE:date}\\] \\\"%{WORD:method} %{PATH:uri} %{DATA:protocol}\\\" %{INT:status} %{INT:length}
filter { grok { match => { \"message\" => \"%{IP:client} %{WORD:method} %{URIPATHPARAM:request} %{NUMBER:bytes} %{NUMBER:duration}\" } }}
常用的Grok模式
为了方便测试,我们可以使用Kibana来进行Grok开发:
vim config/filebeat-console.confinput { beats { port => 5044 }}filter { grok { match => { \"message\" => \"%{IP:ip} - - \\[%{HTTPDATE:date}\\] \\\"%{WORD:method} %{PATH:uri} %{DATA:protocol}\\\" %{INT:status:int} %{INT:length:int}\" }}}output { stdout { codec => rubydebug }}
修改Logstash配置文件
bin/logstash -f config/filebeat-console.conf --config.reload.automatic
启动logstash测试
使用mutate插件过滤掉不需要的字段mutate { enable_metric => \"false\" remove_field => [\"message\
将date字段转换为「年月日 时分秒」格式。默认字段经过date插件处理后,会输出到@timestamp字段,所以,我们可以通过修改target属性来重新定义输出字段。date { match => [\"date\
要将日期格式进行转换,我们可以使用Date插件来实现。该插件专门用来解析字段中的日期,官方说明文档:https://www.elastic.co/guide/en/logstash/7.17/plugins-filters-date.html用法如下:
利用Logstash过滤器解析日志
output { elasticsearch { index => \"tomcat_web_log_%{+YYYY-MM}\" hosts => [\"http://localhost:9200\"] user => \"elastic\" password => \"123456\" } stdout{ codec => rubydebug }}
index来指定索引名称,默认输出的index名称为:logstash-%{+yyyy.MM.dd}。但注意,要在index中使用时间格式化,filter的输出必须包含 @timestamp字段,否则将无法解析日期。注意:index名称中,不能出现大写字符
vim config/filebeat-filter-es.confinput { beats { port => 5044 }}filter { grok { match => { \"message\" => \"%{IP:ip} - - \\[%{HTTPDATE:date}\\] \\\"%{WORD:method} %{PATH:uri} %{DATA:protocol}\\\" %{INT:status:int} %{INT:length:int}\" }}mutate { enable_metric => \"false\" remove_field => [\"message\
完整的Logstash配置文件
启动logstashbin/logstash -f config/filebeat-filter-es.conf --config.reload.automatic
输出到Elasticsearch指定索引
案例:采集tomcat服务器日志Tomcat服务器运行过程中产生很多日志信息,通过Logstash采集并存储日志信息至ElasticSearch中
ELK整合实战
ELK实战和原理
ElasticSearch
本文主要讲Java8的新特性,Java8也是一个重要的版本,在语法层面有更大的改动,支持了lamda表达式,影响堪比Java5的泛型支持。
/** * 静态方法引用:ClassName::methodName * 实例上的实例方法引用:instanceReference::methodName * 超类上的实例方法引用:super::methodName * 类型上的实例方法引用:ClassName::methodName * 构造方法引用:Class::new * 数组构造方法引用:TypeName[]::new * Created by codecraft on 2016-02-05. */public class MethodReference { @Test public void methodRef(){ SampleData.getThreeArtists().stream() .map(Artist::getName) .forEach(System.out::println); } @Test public void constructorRef(){ ArtistFactory<Artist> af = Artist::new; Artist a = af.create(\"codecraft\
lamda表达式(重磅)
集合的stream操作
当hash冲突时,以前都是用链表存储,在java8里头,当节点个数>=TREEIFY_THRESHOLD - 1时,HashMap将采用红黑树存储,这样最坏的情况下即所有的key都Hash冲突,采用链表的话查找时间为O(n),而采用红黑树为O(logn)。
提升HashMaps的性能
Java 8新增了LocalDate和LocalTime接口,一方面把月份和星期都改成了enum防止出错,另一方面把LocalDate和LocalTime变成不可变类型,这样就线程安全了。
@Test public void today(){ LocalDate today = LocalDate.now(); System.out.println(today); } @Test public void parseString(){ // 严格按照ISO yyyy-MM-dd验证,02写成2都不行,当然也有一个重载方法允许自己定义格式 LocalDate date = LocalDate.parse(\"2016-02-05\"); System.out.println(date); } @Test public void calculate(){ LocalDate today = LocalDate.now(); LocalDate firstDayOfThisMonth = today.with(TemporalAdjusters.firstDayOfMonth()); System.out.println(firstDayOfThisMonth); // 取本月第2天: LocalDate secondDayOfThisMonth = today.withDayOfMonth(2); System.out.println(secondDayOfThisMonth); // 取本月最后一天,再也不用计算是28,29,30还是31: LocalDate lastDayOfThisMonth = today.with(TemporalAdjusters.lastDayOfMonth()); System.out.println(lastDayOfThisMonth); // 取下一天: LocalDate nextDay = lastDayOfThisMonth.plusDays(1); System.out.println(nextDay); // 取2015年1月第一个周一,这个计算用Calendar要死掉很多脑细胞: LocalDate firstMondayOf2015 = LocalDate.parse(\"2015-01-01\
Date-Time Package
java.lang and java.util Packages
StampedLock
ConcurrentHashMap的stream支持
Concurrency
lamda表达式(重磅)集合的stream操作提升HashMaps的性能Date-Time Packagejava.lang and java.util PackagesConcurrency
JDK8
G1成为默认垃圾回收器
HTTP/2 Client(Incubator)支持HTTP2,同时改进httpclient的api,支持异步模式。
jshell: The Java Shell (Read-Eval-Print Loop)
Convenience Factory Methods for Collections
JDK9
阅读代码比编写代码更重要使用var应当让读者能够清楚推断出类型代码可读性不应该依赖于IDE显式类型是一种折衷,虽然有时候冗长,但是类型清晰
286: Local-Variable Type Inference(重磅)相关解读: java10系列(二)Local-Variable Type Inference296: Consolidate the JDK Forest into a Single Repository304: Garbage-Collector Interface307: Parallel Full GC for G1310: Application Class-Data Sharing312: Thread-Local Handshakes313: Remove the Native-Header Generation Tool (javah)314: Additional Unicode Language-Tag Extensions316: Heap Allocation on Alternative Memory Devices317: Experimental Java-Based JIT Compiler(重磅)相关解读: Java10来了,来看看它一同发布的全新JIT编译器319: Root Certificates相关解读: OpenJDK 10 Now Includes Root CA Certificates322: Time-Based Release Versioning相关解读: java10系列(一)Time-Based Release Versioning
JDK10
JDK11
JDK版本差异
技术总结
0 条评论
回复 删除
下一页