Java全面面试宝典
2024-04-10 18:34:43 0 举报
AI智能生成
本人一线大厂经验,覆盖全面知识点,脑图更适合记忆
作者其他创作
大纲/内容
java集合
HashMap
使用到的数据结构
jdk1.7:数组+链表
jdk1.8后:数组+链表+红黑树
jdk1.8后:数组+链表+红黑树
HashMap 内部结构:可以看作是数组和链表结合组成的复合结构,数组被分为一个个桶(bucket),每个桶存储有一个或多个Entry对象,每个Entry对象包含三部分hash,key(键)、value(值),next(指向下一个Entry),通过哈希值决定了Entry对象在这个数组的寻址;哈希值相同的Entry对象(键值对),则以链表形式存储。如果链表大小超过树形转换的阈值(TREEIFY_THRESHOLD= 8且table长度大于等于64),链表就会被改造为红黑树形结构。(也就是由原来的node变成treeNode /triː/)
重要成员变量
DEFAULT_INITIAL_CAPACITY = 1 << 4; Hash表默认初始容量16,put的时候才会去创建hash表
MAXIMUM_CAPACITY = 1 << 30; 最大Hash表容量
DEFAULT_LOAD_FACTOR = 0.75f;默认加载因子
TREEIFY_THRESHOLD = 8;链表转红黑树阈值
UNTREEIFY_THRESHOLD = 6;红黑树转链表阈值【在HashMap进行扩容时使用到】
MIN_TREEIFY_CAPACITY = 64;链表转红黑树时hash表最小容量阈值,达不到优先扩容。
面试前先看下内部的执行机制源码(jdk1.8)(笔记)
HashMap链表插入问题
java8之前是头插法,1.8采用尾插法
为什么hashmap的初始容量为16
其实这个问题是并没有说个为什么,只是写HashMap的人认为16这个初始容量是最适中,最适合我们的大量场景。
- 看到网上有人说因为16位运算的时候都是(length-1)1,只要key的hashcode均匀则分布更均匀
数组所有的元素位是否能够100%被利用起来
不一定,如果存在hash碰撞,引入链表结构解决hash冲突,采用尾部插入链表法(1.7采用头插法),链表时间复杂度O(n)。另外还存在装载因子0.75,达到装载数量后会进行2倍扩容
为什么采用(length - 1) & hash(按位与的散列算法),而不采用hash%length
bit位运算的最接近计算机“语言”,所以位运算比取模计算快的多,差不多快了10倍;这个就是为什么hashMap要通过bit位运算的原因。而且在扩容的时候会遍历重新hash--也就是你如果扩容的次数多了,那hash的次数就会远远大于你的元素个数的值
容量为什么一定要是2的指数次幂
- 在hash()计算的时候,是通过bit位运算方式获取table桶的位置,而这个hash()是需要在length为2的指数幂的基础上;保证了在get操作的时候能定位到高低位转移中的高位
- 在jdk1.8中的扩容中,使用高低位指针进行转移链表操作,这里的hash&length(只存在length、0两个情况),等于length为高位,等于0为低位。这里也巧妙的使用了2的指数幂;
- 其他场景中也可能巧妙利用到这个length为2的指数幂
为什么装载因子默认为0.75
首先我们先想想如果装载因子为50%跟100%会有什么问题?
- 50%:能存放的数据太少了,浪费空间,我分配了16的空间,结果你只用50%
- 100%:数据每次存满后,存在hash碰撞的几率就大些。也就影响map的效率
其实在jdk源码中,已经给了我们答案:0.75是基于时间与空间的折中考虑,而且这个在通过数据概率分析(牛顿二项式):不管这个数组长度为多少,当这个扩容因子为0.69xxx的时候是出现hash碰撞概率最小的,然后java就选择了0.75,像其他的语言有的是0.72,反正就是接近0.69xxx这个值
jdk1.8中为什么链表转红黑树的阈值为8
每一个桶出现与不出现数据的概率各为0.5,它这个概率符合一个泊松分布图,随着长度越来越大,概率越来越小。所以虽然jdk1.8中使用红黑树,但真的会转成红黑树的概率特别的小(如果不是刻意),毕竟转红黑树的复杂度还是不小
关于扩容、转红黑树的说明
扩容:扩容的是table的长度length,两倍扩容满足2的指数幂;【put完后会判断本次是否超过阈值,超过了扩容,它不是put之前判断】
- 扩容有两种情况(这个我一个一个看了哪里调了resize(),所以可以肯定):<k,v>数量达到 (扩容因子*table.length)、链表过长超过8且 (table.length<64)
转红黑树:条件是当链表长度大于8,也就是9的时候就会去尝试转红黑树。然后如果table的长度小于64就会优先扩容。注意是table.length 而不是 <k,v> 映射的数量
判断key相等条件
hash相等 && (key地址相等或者equals相等)
e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))
e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))
- hash为hashCode经过位运算后得到(更加散列)
HashMap 多线程操作导致死循环问题
主要原因在于 并发下的Rehash 链表的时候会造成元素之间会形成一个循环链表。不过,jdk 1.8 后解决了这个问题(采用尾插法跟高低位拆分转移方式,避免了链表环的产生),但是还是不建议在多线程下使用 HashMap,因为多线程下使用 HashMap 还是会存在其他问题比如数据丢失。并发环境下推荐使用 ConcurrentHashMap
多线程下get数据为空问题
未发布的写入 (Visibility Issue):
在一个线程中对 HashMap 的更新可能没有被其他线程立即看到,因为没有适当的内存屏障来确保操作的可见性。这意味着,即使一个线程已经将一个键值对放入了 HashMap,其他线程调用 get 方法时依然可能得到 null。
部分更新 (Partial Update):
一个线程可能在更新的过程中被中断,这可能导致 HashMap 处于不一致的状态。举例来说,如果在扩容过程中,新的数组已经创建,但数据还没有完全复制过去,另一个线程此时执行 get 方法,就可能得到一个 null,即使键实际上是存在的。
竞态条件 (Race Condition):
在没有适当同步的情况下,两个线程尝试同时读取和修改 HashMap 可能会导致意料之外的结果。一个线程可能正在读取一个键值对,而另一个线程正在删除它,结果前一个线程可能得到 null。
迭代器失效 (Iterator Invalidation):
如果一个线程正在迭代 HashMap,而另一个线程修改了其结构(比如添加或删除键值对),那么根据 HashMap 的迭代器快速失败(fail-fast)特性,可能抛出 ConcurrentModificationException。在未抛出异常的情况下,迭代器可能会错过一些元素,导致某些 get 调用返回 null。
数据覆盖 (Overwriting Data):
如果在没有适当加锁机制的情况下,两个线程试图同时向 HashMap 中放入相同的键但不同的值,可能会导致其中一个线程的值被另一个线程的值覆盖,进而导致 get() 返回
在一个线程中对 HashMap 的更新可能没有被其他线程立即看到,因为没有适当的内存屏障来确保操作的可见性。这意味着,即使一个线程已经将一个键值对放入了 HashMap,其他线程调用 get 方法时依然可能得到 null。
部分更新 (Partial Update):
一个线程可能在更新的过程中被中断,这可能导致 HashMap 处于不一致的状态。举例来说,如果在扩容过程中,新的数组已经创建,但数据还没有完全复制过去,另一个线程此时执行 get 方法,就可能得到一个 null,即使键实际上是存在的。
竞态条件 (Race Condition):
在没有适当同步的情况下,两个线程尝试同时读取和修改 HashMap 可能会导致意料之外的结果。一个线程可能正在读取一个键值对,而另一个线程正在删除它,结果前一个线程可能得到 null。
迭代器失效 (Iterator Invalidation):
如果一个线程正在迭代 HashMap,而另一个线程修改了其结构(比如添加或删除键值对),那么根据 HashMap 的迭代器快速失败(fail-fast)特性,可能抛出 ConcurrentModificationException。在未抛出异常的情况下,迭代器可能会错过一些元素,导致某些 get 调用返回 null。
数据覆盖 (Overwriting Data):
如果在没有适当加锁机制的情况下,两个线程试图同时向 HashMap 中放入相同的键但不同的值,可能会导致其中一个线程的值被另一个线程的值覆盖,进而导致 get() 返回
put 方法
- 首先通过key进行hash计算:key==null则hash=0。它除了取得key的hashcode值后,还会在这个数的基础上进行位运算,可以理解为让计算的值更加的散列开;
- 判断当前数组是否需要初始化;
- 通过(length - 1) & hash位运算计算得到node在数组桶中的位置,如果位置上不存在值则创建一个节点并放到指定的位置,且这个节点的next=null;.
- 如果桶是一个链表则需要遍历判断里面的 key 是否和传入 key 相等,如果相等则进行覆盖,并返回原来的值;
- 如果说这个链表桶的大小达到了转红黑树的阈值了,就需要去尝试转红黑树,当然转的时候还需要判断当前size(table.length)是否>=64,如果不满足则优先进行扩容table;
- 会判断这一次是否会超过扩容的阈值,超过了就进行两倍扩容(因为必须满足2的指数幂,所以这里是两倍,而不是50%)
get 方法
- 首先将 key hash 之后取得所定位的桶。
- 如果桶为空则直接返回 null 。
- 否则判断桶的第一个位置(有可能是链表、红黑树)的 key 是否为查询的 key,是就直接返回 value。
- 如果第一个不匹配,则判断它的下一个是红黑树还是链表。
- 红黑树就按照树的查找方式返回值。
- 不然就按照链表的方式遍历匹配返回值。
初始和扩容table方法resize()
- 当我们第一次put的时候,需要初始化table桶,Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
- 当需要扩容的时候也是调用resize(),扩容为原来的两倍:创建一个新table,然后再将old-table遍历到新table中:
- 如果桶里面只有一个数据,则重新计算hash,重新位运算定位放入即可;
- 如果桶里面结构为树型,则采用红黑树复制
- 如果桶里面为链表型,在jdk1.7中使用头插入法复制(多线程存在死锁问题)。jdk1.8中使用高低位两个引用,低位的放到原来的位置数上,高位的放到(原来数+oldTab.length)上。在get的时候因为采用bit运算的hash,是能保证准确get到
线程安全问题
- 同时写操作:如果多个线程尝试同时写入 HashMap,可能会导致数据丢失。一个线程的写操作可能会覆盖另一个线程的写操作,从而导致某些键值对未能正确存储到 HashMap 中。
- 某些版本的 HashMap 实现,在并发修改时可能会影响内部数据结构(如链表和树节点之间的关系),以至于出现死循环
- 读写操作并存:即使只有一个线程在写入而其他线程在读取,也可能引起并发问题,如脏读(读取到了错误的数据)、读操作中发生的异常等。比如,如果一个线程在迭代 HashMap 的过程中,另一个线程修改了其结构,可能会抛出 ConcurrentModificationException 异常。
ConcurrentHashMap
ConcurrentHashMap的数据结构与HashMap基本类似,区别在于:
1、内部在数据写入时加了同步机制(分段锁)保证线程安全,读操作是无锁操作;
2、扩容时老数据的转移是并发执行的,这样扩容的效率更高。
jdk1.7 跟 jdk1.8 并发安全控制
jdk1.7中的并发控制原理
原理上来说:ConcurrentHashMap 采用了分段锁技术,其中 Segment /ˈseɡmənt/继承于 ReentrantLock。不会像 HashTable 那样不管是 put 还是 get 操作都需要做同步处理,理论上 ConcurrentHashMap 支持 CurrencyLevel (Segment 数组数量)的线程并发。每当一个线程占用锁访问一个 Segment 时,不会影响到其他的 Segment。所以每次都需要先hash到segment,然后再次hash到hashEntry。跟hashMap一样也是通过bit位运算定位index。其实这个加锁的粒度还是挺大的--加锁的是segment,然后segment下面的HashEntry[]才是真正放数据的地方,然后每个HashEntry又可能是一个链表。比如两个线程都定位到同一个segment,这时候就需要先获取到segment锁,才能进行。如果锁的是segment下面的HashEntry粒度就会小很多(这句话是帮助理解锁segment粒度过大)。segment跟HashEntry[]默认都是16个
jdk1.8中的并发控制原理
Java8中 ConcurrentHashMap基于分段锁+CAS保证线程安全,分段锁基于synchronized关键字实现;当node=null的时候直接通过CAS保证并发,当不为空的时候通过synchronize加锁。这里是通过对每一个node加锁(或者CAS),粒度比jdk1.7的时候对一个大的segment加Lock锁粒度小多了
关键源码分析
jdk1.7 关键源码分析
put 方法
虽然 HashEntry 中的 value 是用 volatile 关键词修饰的,但是并不能保证并发的原子性,所以 put 操作时仍然需要加锁处理。首先第一步的时候会尝试获取锁,如果获取失败肯定就有其他线程存在竞争,则利用 scanAndLockForPut() 自旋获取锁。
- 将当前 Segment 中的 table 通过 key 的 hashcode 定位到 HashEntry。
- 遍历该 HashEntry,如果不为空则判断传入的 key 和当前遍历的 key 是否相等,相等则覆盖旧的 value。
- 不为空则需要新建一个 HashEntry 并加入到 Segment 中,同时会先判断是否需要扩容。
- 最后会解除在 1 中所获取当前 Segment 的锁。
get方法
只需要将 Key 通过 Hash 之后定位到具体的 Segment ,再通过一次 Hash 定位到具体的元素上。由于 HashEntry 中的 value 属性是用 volatile 关键词修饰的,保证了内存可见性,所以每次获取时都是最新值。ConcurrentHashMap 的 get 方法是非常高效的,因为整个过程都不需要加锁
jdk1.8 关键源码分析
put方法
- 根据 key 计算出 hash
- 判断是否需要进行初始化table。如果需要的话,cas保证只有一个线程可以初始化table
- f 即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,通过cas保证同一时刻只有一个线程put成功,失败则自旋保证成功(继续下一次循环,此时不为空通过synchronized同步put )
- 如果当前位置的 hashcode == MOVED == -1,代表着table正在扩容。需要帮忙扩容
- 如果都不满足,则利用 synchronized 锁写入数据。一直找到链表中最后一个node,插到尾部,这里跟jdk1.7有所不同,1.7采用的是头插,1.8是尾插
- 如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树。
初始化table方法initTable()
初始化table:在我们创建好ConcurrentHashMap对象后,sizeCtl(用来标志table初始化和扩容的)存在两种情况:1:无参构造的对象,sizeCtl=0; 2:有参sizeCtl = table.length>0。此时我们第一次put的时候,因为table未初始化,需要进行初始化操作调用initTable()方法。initTable()方法里面通过cas控制了只能有一个线程进行初始化操作,并将sizeCtl改为-1(table正在初始化),初始化完后更改sizeCtl为正数。其他cas失败的,自旋一次就会让出cpu使用权
get方法
1.8 在 1.7 的数据结构上做了大的改动,采用红黑树之后可以保证查询效率(O(logn)),甚至取消了 ReentrantLock 改为了 synchronized,这样可以看出在新版的 JDK 中对 synchronized 优化是很到位的
- 根据计算出来的 hashcode 寻址,如果就在桶上那么直接返回值。
- 如果是红黑树那就按照树的方式获取值。
- 就不满足那就按照链表的方式遍历获取值。
1.8 在 1.7 的数据结构上做了大的改动,采用红黑树之后可以保证查询效率(O(logn)),甚至取消了 ReentrantLock 改为了 synchronized,这样可以看出在新版的 JDK 中对 synchronized 优化是很到位的
读写分离CopyOnWriteArrayList
- 读写分离,写时复制,空间换时间;
- 最终一致性,在写的过程中,原有的读的数据是不会发生更新的,只有新的读才能读到最新数据
- 适用于读多写少的情况,最大程度的提高读的效率
- 写的时候不能并发写,需要对写操作进行加锁;
- 如何使其他线程能够及时读到新的数据,需要使用volatile变量
写
复制一个array副本
往副本里写入
副本替换原本,成为新的原本
读
return get(getArray(), index); //无锁
读的是老数组,所以当有线程在写的时候是读不到的,因为不在一个数组
问题整理
说说List,Set,Map三者的区别?
- List(对付顺序的好帮手): 存储的元素是有序的、可重复的。
- Set(注重独一无二的性质): 存储的元素是无序的、不可重复的。
- Map(用 Key 来搜索的专家): 使用键值对(kye-value)存储,Key 是无序的、不可重复的,value 是无序的、可重复的,每个键最多映射到一个值
Arraylist 与 LinkedList 区别?
是否保证线程安全: ArrayList 和 LinkedList 都是不同步的,也就是不保证线程安全
底层数据结构: Arraylist 底层使用的是 Object 数组;LinkedList 底层使用的是 双向链表 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环。注意双向链表和双向循环链表的区别,下面有介绍到!)
插入和删除是否受元素位置的影响: ① ArrayList 采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。 比如:执行add(E e)方法的时候, ArrayList 会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是 O(1)。但是如果要在指定位置 i 插入和删除元素的话(add(int index, E element))时间复杂度就为 O(n-i)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作。 ② LinkedList 采用链表存储,所以对于add(E e)方法的插入,删除元素时间复杂度不受元素位置的影响,近似 O(1),如果是要在指定位置i插入和删除元素的话((add(int index, E element)) 时间复杂度近似为o(n))因为需要先移动到指定位置再插入
是否支持快速随机访问: LinkedList 不支持高效的随机元素访问,而 ArrayList 支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)方法)
内存空间占用: ArrayList 的空 间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而 LinkedList 的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间(因为要存放直接后继和直接前驱以及数据)
相对于ArrayList,LinkedList的随机访问集合元素时性能较差,因为需要在双向列表中找到要index的位置,再返回;但在插入,删除操作是更快的。因为LinkedList不像ArrayList一样,不需要改变数组的大小,也不需要在数组装满的时候要将所有的数据重新装入一个新的数组,这是ArrayList最坏的一种情况,时间复杂度是O(n),而LinkedList中插入或删除的时间复杂度仅为O(1)。ArrayList在插入数据时还需要更新索引(除了插入数组的尾部)。
不过ArrayList的插入,删除操作也不一定比LinkedList慢,如果在List靠近末尾的地方插入,那么ArrayList只需要移动较少的数据,而LinkedList则需要一直查找到列表尾部,反而耗费较多时间,这时ArrayList就比LinkedList要快
双向链表和双向循环链表
双向链表: 包含两个指针,一个 prev 指向前一个节点,一个 next 指向后一个节点
双向循环链表: 最后一个节点的 next 指向 head,而 head 的 prev 指向最后一个节点,构成一个环。
ArrayList 与 Vector /ˈvektər/区别呢?为什么要用Arraylist取代Vector呢?
ArrayList 是 List 的主要实现类,底层使用 Object[ ]存储,适用于频繁的查找工作,线程不安全 ;
Vector 是 List 的古老实现类,底层使用 Object[ ] 存储,线程安全的
Vector 是 List 的古老实现类,底层使用 Object[ ] 存储,线程安全的
说一说 ArrayList 的扩容机制吧
初始容量为10的数组
新容量更新为旧容量的1.5倍【一开始为0,只有add数据时,才分配默认DEFAULT_CAPACITY = 10的初始容量】
1.7的时候是初始化就创建一个容量为10的数组,1.8后是初始化先创建一个空数组,第一次add时才扩容为10
HashMap 和 Hashtable 的区别
线程是否安全: HashMap 是非线程安全的,HashTable 是线程安全的,因为 HashTable 内部的方法基本都经过synchronized 修饰
效率: 因为线程安全的问题,HashMap 要比 HashTable 效率高一点。另外,HashTable 基本被淘汰(使用ConcurrentHashMap),不要在代码中使用它
对 Null key 和 Null value 的支持: HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;HashTable 不允许有 null 键和 null 值,否则会抛出 NullPointerException
初始容量大小和每次扩充容量大小的不同 : ① 创建时如果不指定容量初始值,Hashtable 默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。② 创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap 会将其扩充为 2 的幂次方大小。
底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制(只有链表)
HashMap 和 HashSet区别
HashSet 底层就是基于 HashMap 实现的(HashSet 的源码非常非常少,因为除了 clone()、writeObject()、readObject()是 HashSet 自己不得不实现之外,其他方法都是直接调用 HashMap 中的方法
HashSet是通过HasMap来实现的,HashMap的输入参数有Key、Value两个组成,在实现HashSet的时候,保持HashMap的Value为常量,相当于在HashMap中只对Key对象进行处理
HashSet如何检查重复
当你把对象加入HashSet时,HashSet 会先计算对象的hashcode值来判断对象加入的位置,同时也会与其他加入的对象的 hashcode 值作比较,如果没有相符的 hashcode,HashSet 会假设对象没有重复出现。但是如果发现有相同 hashcode 值的对象,这时会调用equals()方法来检查 hashcode 相等的对象是否真的相同。如果两者相同,HashSet 就不会让加入操作成功。
==与 equals 的区别
对于基本类型来说,== 比较的是值是否相等
对于引用类型来说,== 比较的是两个引用是否指向同一个对象地址(两者在内存中存放的地址(堆内存地址)是否指向同一个地方)
对于引用类型(包括包装类型)来说,equals 如果没有被重写,对比它们的地址是否相等;如果 equals()方法被重写(例如 String),则比较的是地址里的内容
- 比如Integer就重写了equals方法,比较的是intValue
比较 HashSet、LinkedHashSet 和 TreeSet 三者的异同
- HashSet 是 Set 接口的主要实现类 ,HashSet 的底层是 HashMap,线程不安全的,可以存储 null 值;
- LinkedHashSet 是 HashSet 的子类,能够按照添加的顺序遍历;
- TreeSet 底层使用红黑树,能够按照添加元素的顺序进行遍历,排序的方式有自然排序和定制排序
集合框架底层数据结构总结
List
- Arraylist: Object[]数组
- Vector:Object[]数组
- LinkedList: 双向链表(JDK1.6 之前为循环链表,JDK1.7 取消了循环)
Set
- HashSet(无序,唯一): 基于 HashMap 实现的,底层采用 HashMap 来保存元素
- LinkedHashSet:LinkedHashSet 是 HashSet 的子类,并且其内部是通过 LinkedHashMap 来实现的。有点类似于我们之前说的 LinkedHashMap 其内部是基于 HashMap 实现一样,不过还是有一点点区别的
- TreeSet(有序,唯一): 红黑树(自平衡的排序二叉树)
Map
HashMap: JDK1.8 之前 HashMap 由数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间
LinkedHashMap: LinkedHashMap 继承自 HashMap,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,LinkedHashMap 在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑
Hashtable: 数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的
TreeMap: 红黑树(自平衡的排序二叉树)
ConcurrentHashMap 不支持 key 或者 value 为 null 的原因?
value 为什么不能为 null
因为 ConcurrentHashMap 是用于多线程的 ,如果ConcurrentHashMap.get(key)得到了 null ,这就无法判断,是映射的value是 null ,还是没有找到对应的key而为 null ,就有了二义性
我们用反证法来推理:
假设 ConcurrentHashMap 允许存放值为 null 的 value,这时有A、B两个线程,线程A调用ConcurrentHashMap.get(key)方法,返回为 null ,我们不知道这个 null 是没有映射的 null ,还是存的值就是 null 。
假设此时,返回为 null 的真实情况是没有找到对应的 key。那么,我们可以用 ConcurrentHashMap.containsKey(key)来验证我们的假设是否成立,我们期望的结果是返回 false 。
但是在我们调用 ConcurrentHashMap.get(key)方法之后,containsKey方法之前,线程B执行了ConcurrentHashMap.put(key, null)的操作。那么我们调用containsKey方法返回的就是 true 了,这就与我们的假设的真实情况不符合了,这就有了二义性。(因为可能是key没映射也有可能是value为空,所以你无法通过containsKey判断出来,而hashmap则可以通过containsKey判断出来)
我们用反证法来推理:
假设 ConcurrentHashMap 允许存放值为 null 的 value,这时有A、B两个线程,线程A调用ConcurrentHashMap.get(key)方法,返回为 null ,我们不知道这个 null 是没有映射的 null ,还是存的值就是 null 。
假设此时,返回为 null 的真实情况是没有找到对应的 key。那么,我们可以用 ConcurrentHashMap.containsKey(key)来验证我们的假设是否成立,我们期望的结果是返回 false 。
但是在我们调用 ConcurrentHashMap.get(key)方法之后,containsKey方法之前,线程B执行了ConcurrentHashMap.put(key, null)的操作。那么我们调用containsKey方法返回的就是 true 了,这就与我们的假设的真实情况不符合了,这就有了二义性。(因为可能是key没映射也有可能是value为空,所以你无法通过containsKey判断出来,而hashmap则可以通过containsKey判断出来)
key为什么不能为空
这个问题其实有人问过了,而且Doug老爷子也回答了,就是value为空存在二义性,但是为什么key不能为空则没有明确说明。doug认为不管容器是否考虑了线程安全问题,都不应该允许null值的出现。他觉得在现有的某些集合里面允许了null值的出现,是集合的设计问题。他也一直在和Josh Bloch讨论这个事情
- Doug对Tutika遇到的问题给出了自己的建议:可以定义一个名称为NULL的全局的Object。当需要用null值的时候,用这个NULL来代替,以假乱真
rocketmq
主从集群架构10
普通集群
这种集群模式下会给每个节点分配一个固定的角色,master负责响应客户端的请求,并存储消息。slave /sleɪv/则只负责对master的消息进行同步保存,并响应部分客户端的读请求。消息同步方式分为同步同步和异步同步。这种集群模式下各个节点的角色无法进行切换,也就是说,master节点挂了,这一组Broker就不可用了
Dledger [d-/ˈledʒər/] 高可用集群(4.5版本后)
Dledger是RocketMQ自4.5版本引入的实现高可用集群的一项技术。这个模式下的集群会随机选出一个节点作为master,而当master节点挂了后,会从slave中自动选出一个节点升级成为master。
Dledger技术做的事情:1、接管Broker的CommitLog消息存储 2、从集群中选举出master节点 3、完成master节点往slave节点的消息同步。
Dledger技术做的事情:1、接管Broker的CommitLog消息存储 2、从集群中选举出master节点 3、完成master节点往slave节点的消息同步。
在Raft协议中,会将时间分为一些任意时间长度的时间片段,叫做term。term会使用一个全局唯一,连续递增的编号作为标识,也就是起到了一个逻辑时钟的作用。在每个term时间片中都会触发选举leader,所以说它天然的不会发生脑裂的问题
consumer端
拉取消费跟推送消费模型
拉取式消费的应用通常主动调用Consumer的拉消息方法从Broker服务器拉消息、主动权由应用控制。一旦获取了批量消息,应用就会启动消费过程。
推动式消费模式下Broker收到数据后会主动推送给消费端,该消费模式一般实时性较高(其实源码中也是伪push,它实际上是死循环拉取的伪实现方式)。
广播模式跟集群模式
集群消费模式下,相同Consumer Group的每个Consumer实例平均分摊消息。
广播消费模式下,相同Consumer Group的每个Consumer实例都接收全量的消息。
消费顺序模式
普通顺序消费模式下,消费者通过同一个消费队列收到的消息是有顺序的,不同消息队列收到的消息则可能是无顺序的
严格顺序消息模式下,消费者收到的所有消息均是有顺序的。
消费点位
CONSUME_FROM_LAST_OFFSET 将会忽略历史消息,并消费之后生成的任何消息
CONSUME_FROM_FIRST_OFFSET 将会消费每个存在于 Broker 中的信息
CONSUME_FROM_TIMESTAMP 消费在指定时间戳后产生的消息
集群模式消息消费进度存储在Broker(消息服务器),广播模式消息消费进度存储在消费者端
在 RocketMQ 中,消费偏移量被存储在 Broker 端。Consumer 在处理消息完成后,会定期向 Broker 提交消费进度(消费偏移量),这通常是通过发送心跳包的方式进行的。心跳包除了包含消费者的存活状态,还包括其消费偏移量信息。RocketMQ 的消费者可以选择同步或异步提交其消费偏移量
producer端
三种发送方式
Sync:同步的发送方式,会等待发送结果后才返回
Async:异步的发送方式,发送完后,立刻返回。Client 在拿到 Broker 的响应结果后,会回调指定的 callback. 这个 API 也可以指定 Timeout,不指定也是默认的 3000ms.
Oneway:比较简单,发出去后,什么都不管直接返回。
四种消息发送结果
SEND_OK
消息发送成功。注意的是消息发送成功也不意味着它是可靠的。要确保不会丢失任何消息,还应启用同步Master服务器或同步刷盘,即SYNC_MASTER或 SYNC_FLUSH
消息发送成功。注意的是消息发送成功也不意味着它是可靠的。要确保不会丢失任何消息,还应启用同步Master服务器或同步刷盘,即SYNC_MASTER或 SYNC_FLUSH
FLUSH_DISK_TIMEOUT
消息发送成功但是服务器刷盘超时。此时消息已经进入服务器队列(内存),只有服务器宕机,消息才会丢失。消息存储配置参数中可以设置刷盘方式和同步刷盘时间长度,如果Broker服务器设置了刷盘方式为同步刷盘,即FlushDiskType=SYNC_FLUSH(默认为异步刷盘方式),当Broker服务器未在同步刷盘时间内(默认为5s)完成刷盘,则将返回该状态——刷盘超时
FLUSH_SLAVE_TIMEOUT
消息发送成功,但是服务器同步到Slave时超时。此时消息已经进入服务器队列,只有服务器宕机,消息才会丢失。如果Broker服务器的角色是同步Master,即SYNC_MASTER(默认是异步Master即ASYNC_MASTER),并且slave Broker服务器未在同步刷盘时间(默认为5秒)内完成与master服务器的同步,则将返回该状态——数据同步到Slave服务器超时
SLAVE_NOT_AVAILABLE
消息发送成功,但是此时Slave不可用。如果Broker服务器的角色是同步Master,即SYNC_MASTER(默认是异步Master服务器即ASYNC_MASTER),但没有配置slaveBroker服务器,则将返回该状态——无Slave服务器可用
高级特性
顺序消息
在默认的情况下消息发送会采取Round Robin轮询方式把消息发送到不同的queue(分区队列);而消费消息的时候从多个queue上拉取消息,这种情况发送和消费是不能保证顺序。但是如果控制发送的顺序消息只依次发送到同一个queue中,消费的时候只从这个queue上依次拉取,则就保证了顺序。当发送和消费参与的queue只有一个,则是全局有序;如果多个queue参与,则为分区有序,即相对每个queue,消息都是有序的
延时消息
开源版本中只能设置延迟级别,总共18种级别
批量消息
批量发送消息能显著提高传递小消息的性能。限制是这些批量消息应该有相同的topic,相同的waitStoreMsgOK,而且不能是延时消息。此外,这一批消息的总大小不应超过4MB。rocketmq建议每次批量消息大小大概在1MB。当消息大小超过4MB时,需要将消息进行分割
过滤消息
使用TAG /tæɡ/ 来过滤消息
使用RocketMQ定义的SQL表达式过滤
事务消息
三种消息状态
COMMIT_MESSAGE,//提交事务,它允许消费者消费此消息
ROLLBACK_MESSAGE,//回滚事务,它代表该消息将被删除,不允许被消费
UNKNOW;//中间状态,它代表需要检查消息队列来确定状态,三天后删除
消息交互流程
事务消息发送步骤
1、发送方将半事务消息发送至消息队列 MQ 服务端。
2、消息队列 MQ 服务端将消息持久化成功之后,向发送方返回 Ack 确认消息已经发送成功,此时消息为半事务消息。
3、发送方开始执行本地事务逻辑
4、发送方根据本地事务执行结果向服务端提交二次确认(Commit 或是 Rollback),服务端收到 Commit 状态则将半事务消息标记为可投递,订阅方最终将收到该消息;服务端收到Rollback 状态则删除半事务消息,订阅方将不会接受该消息
事务消息回查步骤
1、在断网或者是应用重启的特殊情况下,上述步骤 4 提交的二次确认最终未到达服务端,经过固定时间后服务端将对该消息发起消息回查
2、发送方收到消息回查后,需要检查对应消息的本地事务执行的最终结果。
3、发送方根据检查得到的本地事务的最终状态再次提交二次确认,服务端仍按照步骤 4对半事务消息进行操作。
事务消息限制
1、事务消息不支持延时消息和批量消息。
2、为了避免单个消息被检查太多次而导致半队列消息累积,我们默认将单个消息的检查次数限 制为 15 次,但是用户可以通过 Broker 配置文件的 transactionCheckMax参数来修改此限制。如果已经检查某条消息超过 N 次的话( N = transactionCheckMax ) 则 Broker将丢弃此消息,并在默认情况下同时打印错误日志。用户可以通过重写 AbstractTransactionCheckListener类来修改这个行为。
3、事务消息将在 Broker 配置文件中的参数 transactionMsgTimeout 这样的特定时间长度之后被检查。当发送事务消息时,用户还可以通过设置用户属性 CHECK_IMMUNITY_TIME_IN_SECONDS来改变这个限制,该参数优先于 transactionMsgTimeout 参数
4、事务性消息可能不止一次被检查或消费,所以消费者需要做幂等
5、提交给用户的目标主题消息可能会失败,目前这依日志的记录而定。它的高可用性通过RocketMQ 本身的高可用性机制来保证,如果希望确保事务消息不丢失、并且事务完整性得到保证,建议使用同步的双重写入机制(意思就是说在发送事务消息同时保存一份消息到db上)
6、事务消息的生产者 ID 不能与其他类型消息的生产者 ID 共享。与其他类型的消息不同,事务消息允许反向查询、MQ服务器能通过它们的生产者 ID 查询到消费者
消息交互流程图
rocketmq高级原理
Broker架构跟Namesrv架构
Namesrv架构
Broker管理
NameServer接受Broker集群的注册信息并且保存下来作为路由信息的基本数据。然后提供心跳检测机制,检查Broker是否还存活
路由信息管理
每个NameServer将保存关于Broker集群的整个路由信息和用于客户端查询的队列信息(不然消费者怎么知道我到哪找我要的topic)
Broker架构
Remoting Module:整个Broker的实体,负责处理来自clients端的请求
Client Manager:负责管理客户端(Producer/Consumer)和维护Consumer的Topic订阅信息
Store Service:提供方便简单的API接口处理消息存储到物理硬盘和查询功能。
HA Service:高可用服务,提供Master Broker 和 Slave Broker之间的数据同步功能
Index Service:根据特定的Message key对投递到Broker的消息进行索引服务,以提供消息的快速查询
消息存储结构
CommitLog
消息主体以及元数据的存储主体。顺序写入,消息内容不是定长的,理解成消息拼接。文件固定大小1G,文件名20位,左边补0,剩余为起始偏移量,如果文件写满,创建第二个文件,且文件名为起始偏移量
Kafka每个分区都有个commitlog文件,而rmq都在同一个log中,所以当topic多的时候rmq变现更好(延迟更低),因为文件多了顺序读写会影响到
ConsumeQueue
一个queue一个consumeQueue文件,ConsumeQueue(逻辑消费队列)作为消费消息的索引,保存了指定Topic下的队列消息在CommitLog中的起始物理偏移量offset,消息大小size和消息Tag的HashCode值。consumequeue文件可以看成是基于topic的commitlog索引文件。采取定长设计,每一个条目共20个字节,分别为8字节的commitlog物理偏移量、4字节的消息长度、8字节tag hashcode,单个文件由30W个条目组成,可以像数组一样随机访问每一个条目,每个ConsumeQueue文件大小约5.72M
每个topic的队列会分布在不同的broker上,所以每台broker只保存自己的queue文件,并不是一个topic文件夹下面就有它全部的队列(比如brokerA的xxxx队列文件夹下只有1这个queue的文件夹)
IndexFile
IndexFile(索引文件)提供了一种可以通过key或时间区间来查询消息的方法。Index文件的存储位置是:$HOME \store\index${fileName},文件名fileName是以创建时的时间戳命名的,固定的单个IndexFile文件大小:40+500W*4+2000W*20=420000040个字节大小,约为400M,一个IndexFile可以保存 2000W个索引,IndexFile的底层存储设计为在文件系统中实现HashMap结构,故rocketmq的索引文件其底层实现为hash索引。索引文件由索引文件头 40byte(IndexHeader)+4byte( 槽位 Slot )+20byte(消息的索引内容)三部分构成
config/*.json
这些文件是将RocketMQ的一些关键配置信息进行存盘保存。例如Topic配置、消费者组配置、消费者组消息偏移量Offset 等等一些信息
abort
这个文件是RocketMQ用来判断程序是否正常关闭的一个标识文件。正常情况下,会在启动时创建,而关闭服务时删除。但是如果遇到一些服务器宕机,或者kill -9这样一些非正常关闭服务的情况,这个abort文件就不会删除,因此RocketMQ就可以判断上一次服务是非正常关闭的,后续就会做一些数据恢复的操作
checkpoint
数据存盘检查点
图
消息查询
按照MessageId查询消息
RocketMQ中的MessageId的长度总共有16字节,其中包含了消息存储主机地址(IP地址和端口),消息Commit Log offset。“按照MessageId查询消息”在RocketMQ中具体做法是:Client端从MessageId中解析出Broker的地址(IP地址和端口)和Commit Log 的偏移地址后封装成一个RPC请求后通过Remoting通信层发送(业务请求码:VIEW_MESSAGE_BY_ID)。Broker端走的是QueryMessageProcessor,读取消息的过程用其中的 commitLog offset 和 size 去 commitLog 中找到真正的记录并解析成一个完整的消息返回
按照Message Key查询消息
按照Message Key查询消息,主要是基于RocketMQ的IndexFile索引文件来实现的。RocketMQ的索引文件逻辑结构,类似JDK中HashMap的实现,按照Message Key查询消息”的方式,RocketMQ的具体做法是,主要通过Broker端的QueryMessageProcessor业务处理器来查询,读取消息的过程就是用topic和key找到IndexFile索引文件中的一条记录,根据其中的commitLog offset从CommitLog文件中读取消息的实体内容
消息刷盘
同步刷盘
只有在消息真正持久化至磁盘后RocketMQ的Broker端才会真正返回给Producer端一个成功的ACK响应。同步刷盘对MQ消息可靠性来说是一种不错的保障,但是性能上会有较大影响,一般适用于金融业务应用该模式较多
异步刷盘
能够充分利用OS【操作系统】内存的PageCache的优势,只要消息写入PageCache即可将成功的ACK返回给Producer端。消息刷盘采用后台异步线程提交的方式进行,降低了读写延迟,提高了MQ的性能和吞吐量
配置方式
刷盘方式是通过Broker配置文件里的flushDiskType 参数设置的,这个参数被配置成SYNC_FLUSH(同步)、ASYNC_FLUSH(异步)中的 一个
页缓存与内存映射
页缓存(PageCache)是OS对文件的缓存,用于加速对文件的读写。一般来说,程序对文件进行顺序读写的速度几乎接近于内存的读写速度,主要原因就是由于OS使用PageCache机制对读写访问操作进行了性能优化,将一部分的内存用作PageCache。对于数据的写入,OS会先写入至Cache内,随后通过异步的方式由pdflush内核线程将Cache内的数据刷盘至物理磁盘上。对于数据的读取,如果一次读取文件时出现未命中PageCache的情况,OS从物理磁盘上访问读取文件的同时,会顺序对其他相邻块的数据文件进行预读取
消息主从复制
同步复制
同步复制是等Master和Slave都写入消息成功后才反馈给客户端写入成功的状态。在同步复制下,如果Master节点故障,Slave上有全部的数据备份,这样容易恢复数据。但是同步复制会增大数据写入的延迟,降低系统的吞吐量
异步复制
异步复制是只要master写入消息成功,就反馈给客户端写入成功的状态。然后再异步的将消息复制给Slave节点。在异步复制下,系统拥有较低的延迟和较高的吞吐量。但是如果master节点故障,而有些数据没有完成复制,就会造成数据丢失
配置方式
消息复制方式是通过Broker配置文件里的brokerRole参数进行设置的,这个参数可以被设置成ASYNC_MASTER(异步)、 SYNC_MASTER(同步)、SLAVE三个值中的一个
负载均衡
Producer负载均衡
Producer发送消息时,默认会轮询目标Topic下的所有MessageQueue,并采用递增取模(也就是轮训,比如count++,count%queueNum)的方式往不同的MessageQueue上发送消息,以达到让消息平均落在不同的queue上的目的。而由于MessageQueue是分布在不同的Broker上的,所以消息也会发送到不同的broker上
Consumer负载均衡
集群模式
负载理解
在集群消费模式下,每条消息只需要投递到订阅这个topic的Consumer Group下的一个实例即可。RocketMQ采用主动拉取的方式拉取并消费消息,在拉取的时候需要明确指定拉取哪一条message queue。
而每当实例的数量有变更,都会触发一次所有实例的负载均衡,这时候会按照queue的数量和实例的数量平均分配queue给每个实例
六种内置的分配的算法
AllocateMessageQueueAveragely:平均分配(默认)。将所有MessageQueue平均分给每一个消费者
AllocateMessageQueueAveragelyByCircle: 轮询分配。轮流的给一个消费者分配一个MessageQueue
AllocateMessageQueueByConfig: 不分配,直接指定一个messageQueue列表。类似于广播模式,直接指定所有队列
AllocateMessageQueueByMachineRoom:按逻辑机房的概念进行分配。又是对BrokerName和ConsumerIdc有定制化的配置
AllocateMessageQueueConsistentHash。源码中有测试代码AllocateMessageQueueConsitentHashTest。这个一致性哈希策略只需要指定一个虚拟节点数,是用的一个哈希环的算法,虚拟节点是为了让Hash数据在换上分布更为均匀
AllocateMachineRoomNearby: 将同机房的Consumer和Broker优先分配在一起。
1.Consumer启动之时执行start方法主动执行负载均衡逻辑;
2.定时任务触发;
3.Broker下发通知告知Client需要进行负载均衡;
广播模式
广播模式下,每一条消息都会投递给订阅了Topic的所有消费者实例,所以也就没有消息分配这一说。而在实现上,就是在Consumer分配Queue时,所有Consumer都分到所有的Queue
消息重试
Producer端重试
Producer的send方法本身支持内部重试,重试逻辑如下:
- 1:至多重试2次(同步发送为2次,异步发送为0次)
- 2:如果发送失败,则轮转到下一个Broker。这个方法的总耗时不超过sendMsgTimeout设置的值,默认10s
- 3:如果本身向broker发送消息产生超时异常,就不会再重试。
Consumer端重试
可以通过((MessageExt) msg).getReconsumeTimes()获取到消息重试次数,进而业务处理,比如超过三次就落db人工兜底
注意 消费者和生产者的重试还是有区别的:
- 默认重试次数:Product默认是2次,而Consumer默认是16次。
- 重试时间间隔:Product是立刻重试,而Consumer是有一定时间间隔的。
- Product在异步情况重试失效,而对于Consumer在广播情况下重试失效
重试消息rocketmq内部如何处理
RocketMQ会为每个消费组都设置一个Topic名称为“%RETRY%+consumerGroup”的重试队列(这里需要注意的是,这个Topic的重试队列是针对消费组,而不是针对每个Topic设置的),用于暂时保存因为各种异常而导致Consumer端无法消费的消息。考虑到异常恢复起来需要一些时间,会为重试队列设置多个重试级别,每个重试级别都有与之对应的重新投递延时,重试次数越多投递延时就越大。RocketMQ对于重试消息的处理是先保存至Topic名称为“SCHEDULE_TOPIC_XXXX”的延迟队列中,后台定时任务按照对应的时间进行Delay【延迟】后重新保存至“%RETRY%+consumerGroup”的重试队列中
4.7.1版本前每次重试的msgId并不会变化,4.7.1开始都会发生变化,所以不推荐使用这个msgId作为幂等性判断
死信队列
当一条消息消费失败,RocketMQ就会自动进行消息重试。而如果消息超过最大重试次数,RocketMQ就会认为这个消息有问题。但是此时,RocketMQ不会立刻将这个有问题的消息丢弃,而会将其发送到这个消费者组对应的一种特殊队列:死信队列。死信队列的名称是%DLQ%+ConsumGroup
通常,一条消息进入了死信队列,意味着消息在消费处理的过程中出现了比较严重的错误,并且无法自行恢复。此时,一般需要人工去查看死信队列中的消息,对错误原因进行排查。然后对死信消息进行处理,比如转发到正常的Topic重新进行消费,或者丢弃
死信队列的特征
一个死信队列对应一个ConsumGroup,而不是对应某个消费者实例
如果一个ConsumeGroup没有产生死信队列,RocketMQ就不会为其创建相应的死信队列
一个死信队列包含了这个ConsumeGroup里的所有死信消息,而不区分该消息属于哪个Topic
死信队列中的消息不会再被消费者正常消费,除非人工去修改队列的权限
死信队列的有效期跟正常消息相同。默认3天,对应broker.conf中的fileReservedTime属性。超过这个最长时间的消息都会被删除,而不管消息是否消费过
问题整理
使用RocketMQ如何保证消息不丢失?
消息丢失环节
1:生产者发送消息到broker丢失消息
2:master节点同步消息到slave丢失
3:消息刷盘丢失消息
4:消费者消费的时候实际没消费成功结果ack返回的成功,比如你异步去处理消息,主线程返回成功ack
解决方案
生产者使用事务消息机制,当然直接采用同步发送机制也不是不行,productor存在重试机制(虽然只有两次)
Broker配置同步刷盘+Dledger主从架构
消费者不要使用异步消费
整个MQ挂了之后准备降级方案
使用RocketMQ如何保证消息顺序?
局部有序
对于局部有序的要求,只需要将有序的一组消息都存入同一个MessageQueue里,这样MessageQueue的FIFO设计天生就可以保证这一组消息的有序。RocketMQ中,可以在发送者发送消息时指定一个MessageSelector对象(消费者不能哦,但是可以自定义消费者负载),让这个对象来决定消息发入哪一个MessageQueue。这样就可以保证一组有序的消息能够发到同一个MessageQueue里(通过同一组消息的唯一标识对queue数量取模,比如orderId)
全局有序
只能通过保证只有一个队列来进行处理,但有多少这种需求?反而降低性能,都没必要用mq了
使用RocketMQ如何快速处理积压消息?
如何确定RocketMQ有大量的消息积压?
直接通过控制台查看消息消费差值
也可以通过mqadmin指令在后台检查各个Topic的消息延迟情况
还有RocketMQ也会在他的 ${storePathRootDir}/config 目录下落地一系列的json文件,也可以用来跟踪消息积压情况
如何处理大量积压的消息?
Topic下的MessageQueue配置得是足够多
如果Topic下的MessageQueue配置得是足够多的,那每个Consumer实际上会分配多个MessageQueue来进行消费。这个时候,就可以简单的通过增加Consumer的服务节点数量来加快消息的消费,等积压消息消费完了,再恢复成正常情况。最极限的情况是把Consumer的节点个数设置成跟MessageQueue的个数相同。但是如果此时再继续增加Consumer的服务节点就没有用了
Topic下的MessageQueue配置不多
而如果Topic下的MessageQueue配置得不够多的话,那就不能用上面这种增加Consumer节点个数的方法了(MessageQueue跟consumer一一对应的,多了分不到MessageQueue)。这时怎么办呢? 这时如果要快速处理积压的消息,可以创建一个新的Topic,配置足够多的MessageQueue。然后把所有消费者节点的目标Topic转向新的Topic,并紧急上线一组新的消费者,只负责消费旧Topic中的消息,并转储到新的Topic中,这个速度是可以很快的。然后在新的Topic上,就可以通过增加消费者个数来提高消费速度了。之后再根据情况恢复成正常情况。(这两个说到底还是增加消费者数量)
为什么使用mq?具体的使用场景是什么?
根据具体工作中的场景说明,比如解耦啊,削峰啊,如果没有用到就举两个案例说明
基于什么做的mq选型?
需要对接的厂家多,所以采用分布式架构的rocketmq
开源的框架多适配多,需要通过cancel将轨迹同步到es中,在当前中只有rocketmq跟kafka提供了cancel的适配器
个别场景数据量比较大,比如车辆轨迹、人员轨迹等
功能全面,由于不同的业务场景,可能会用到顺序消息、事务消息等。
RocketMQ实现原理?
RocketMQ由NameServer注册中心集群、Producer生产者集群、Consumer消费者集群和若干Broker(RocketMQ进程)组成,它的架构原理是这样的
- 1:Broker在启动的时候去向所有的NameServer注册,并保持长连接,每30s发送一次心跳
- 2:Producer在发送消息的时候从NameServer获取Broker服务器地址,根据负载均衡算法选择一台服务器来发送消息
- 3:Conusmer消费消息的时候同样从NameServer获取Broker地址,然后主动拉取消息来消费
为什么RocketMQ不使用Zookeeper作为注册中心呢?
我认为有以下几个点是不使用zookeeper的原因:
根据CAP理论,同时最多只能满足两个点,而zookeeper满足的是CP,也就是说zookeeper并不能保证服务的可用性,zookeeper在进行选举的时候,整个选举的时间太长,期间整个集群都处于不可用的状态,而这对于一个注册中心来说肯定是不能接受的,作为服务发现来说就应该是为可用性而设计。
基于性能的考虑,NameServer本身的实现非常轻量,而且可以通过增加机器的方式水平扩展,增加集群的抗压能力,而zookeeper的写是不可扩展的,而zookeeper要解决这个问题只能通过划分领域,划分多个zookeeper集群来解决,首先操作起来太复杂,其次这样还是又违反了CAP中的A的设计,导致服务之间是不连通的。
持久化的机制来带的问题,ZooKeeper 的 ZAB 协议对每一个写请求,会在每个 ZooKeeper 节点上保持写一个事务日志,同时再加上定期的将内存数据镜像(Snapshot)到磁盘来保证数据的一致性和持久性,而对于一个简单的服务发现的场景来说,这其实没有太大的必要,这个实现方案太重了。而且本身存储的数据应该是高度定制化的。
消息发送应该弱依赖注册中心,而RocketMQ的设计理念也正是基于此,生产者在第一次发送消息的时候从NameServer获取到Broker地址后缓存到本地,如果NameServer整个集群不可用,短时间内对于生产者和消费者并不会产生太大影响。
根据CAP理论,同时最多只能满足两个点,而zookeeper满足的是CP,也就是说zookeeper并不能保证服务的可用性,zookeeper在进行选举的时候,整个选举的时间太长,期间整个集群都处于不可用的状态,而这对于一个注册中心来说肯定是不能接受的,作为服务发现来说就应该是为可用性而设计。
基于性能的考虑,NameServer本身的实现非常轻量,而且可以通过增加机器的方式水平扩展,增加集群的抗压能力,而zookeeper的写是不可扩展的,而zookeeper要解决这个问题只能通过划分领域,划分多个zookeeper集群来解决,首先操作起来太复杂,其次这样还是又违反了CAP中的A的设计,导致服务之间是不连通的。
持久化的机制来带的问题,ZooKeeper 的 ZAB 协议对每一个写请求,会在每个 ZooKeeper 节点上保持写一个事务日志,同时再加上定期的将内存数据镜像(Snapshot)到磁盘来保证数据的一致性和持久性,而对于一个简单的服务发现的场景来说,这其实没有太大的必要,这个实现方案太重了。而且本身存储的数据应该是高度定制化的。
消息发送应该弱依赖注册中心,而RocketMQ的设计理念也正是基于此,生产者在第一次发送消息的时候从NameServer获取到Broker地址后缓存到本地,如果NameServer整个集群不可用,短时间内对于生产者和消费者并不会产生太大影响。
Broker是怎么保存数据的?
Master和Slave之间是怎么同步数据的呢?
而消息在master和slave之间的同步是根据raft协议来进行的:
- 在broker收到消息后,会被标记为uncommitted状态
- 然后会把消息发送给所有的slave
- slave在收到消息之后返回ack响应给master
- master在收到超过半数的ack之后,把消息标记为committed
- 发送committed消息给所有slave,slave也修改状态为committed
RocketMQ为什么速度快?
是因为使用了顺序存储、Page Cache和异步刷盘。
- 我们在写入commitlog的时候是顺序写入的,这样比随机写入的性能就会提高很多
- 写入commitlog的时候并不是直接写入磁盘,而是先写入操作系统的PageCache,最后由操作系统异步将缓存中的数据刷到磁盘
说说rocketmq的事务消息?
说说rocketmq延迟消息实现原理?
1:生产者发送消息后,拦截消息并修改消息的topic和队列信息;
2:将消息转发到延迟队列中;
3;scheduleMessageService延迟消息服务消费消息;
4:将消息重新存回commitLog中去;
5:将消息转存到目标topic的队列中;
6:消费者消费目标topic消息
2:将消息转发到延迟队列中;
3;scheduleMessageService延迟消息服务消费消息;
4:将消息重新存回commitLog中去;
5:将消息转存到目标topic的队列中;
6:消费者消费目标topic消息
rocketmq会给每个Level设置一个定时器,从ScheduledConsumeQueue中读取信息,这样发送的消息是不需要排序的,定时器只要定时扫描对应的队列下第一个消息,到期就转移到正确队列去
为什么说不需要排序?
比如先发一条level是5s的消息,再发一条level是3s的消息,因为他们会属于不同的ScheduleQueue所以投递顺序能保持正确。你就算后面再发一条level是3s的消息也是依旧在后面
那如果说需要设计成可指定延迟时间的功能就需要对消息进行排序
为什么说不需要排序?
比如先发一条level是5s的消息,再发一条level是3s的消息,因为他们会属于不同的ScheduleQueue所以投递顺序能保持正确。你就算后面再发一条level是3s的消息也是依旧在后面
那如果说需要设计成可指定延迟时间的功能就需要对消息进行排序
netty
同步非阻塞NIO
同步非阻塞,服务器实现模式为一个线程可以处理多个请求(连接),客户端发送的连接请求都会注册到多路复用器selector上,多路复用器轮询到连接有IO请求就进行处理
I/O多路复用底层一般用的Linux API(select,poll,epoll)来实现
Java NIO根据操作系统不同,针对NIO中的Selector有不同的实现,Linux下一般默认就是epoll,至于为什么默认为epoll则是Linux的设置了
操作系统层面如何处理socket
网卡将数据写入内存
网卡收到网线传来的数据、经过 硬件电路的传输、最终将数据写入到内存中的某个地址上
了解 Epoll 本质的第二步,要从 CPU 的角度来看数据接收。理解这个问题,要先了解一个概念:中断。
计算机执行程序时,会有优先级的需求。比如,当计算机收到断电信号时,它应立即去保存数据,保存数据的程序具有较高的优先级(电容可以保存少许电量,供 CPU 运行很短的一小段时间)。
一般而言,由硬件产生的信号需要 CPU 立马做出回应,不然数据可能就丢失了,所以它的优先级很高。
CPU 理应中断掉正在执行的程序,去做出响应;当 CPU 完成对硬件的响应后,再重新执行用户程序。
中断的过程它和函数调用差不多,只不过函数调用是事先定好位置,而中断的位置由“信号”决定
了解 Epoll 本质的第二步,要从 CPU 的角度来看数据接收。理解这个问题,要先了解一个概念:中断。
计算机执行程序时,会有优先级的需求。比如,当计算机收到断电信号时,它应立即去保存数据,保存数据的程序具有较高的优先级(电容可以保存少许电量,供 CPU 运行很短的一小段时间)。
一般而言,由硬件产生的信号需要 CPU 立马做出回应,不然数据可能就丢失了,所以它的优先级很高。
CPU 理应中断掉正在执行的程序,去做出响应;当 CPU 完成对硬件的响应后,再重新执行用户程序。
中断的过程它和函数调用差不多,只不过函数调用是事先定好位置,而中断的位置由“信号”决定
操作系统如何知道接收了数据?
当网卡把数据写入到内存后,网卡向 CPU 发出一个中断信号,操作系统便能得知有新数据到来,再通过网卡中断程序去处理数据【一般而言,由硬件产生的信号需要 CPU 立马做出回应,不然数据可能就丢失了,所以它的优先级很高】
进程阻塞为什么不占用 CPU 资源?
Socket 对象包含了发送缓冲区、接收缓冲区与等待队列等成员。等待队列是个非常重要的结构,它指向所有需要等待该 Socket 事件的进程
处于等待中的进程会被放到Socket的等待队列中,处于运行中进行会被放到cpu的工作队列中表示运行状态,
所以处于等待中的进程或者线程并不会占用CPU资源
如何唤醒进程?
当操作系统知道有数据过来了会将该socket等待队列中的进程重新放入CPU的工作队列中,此时进程会继续执行
操作系统如何知道网络数据对应于哪个 Socket?
因为一个 Socket 对应着一个端口号,而网络数据包中包含了 IP 和端口的信息,内核可以通过端口号找到对应的 Socket。
当然,为了提高处理速度,操作系统会维护端口号到 Socket 的索引结构,以快速读取
select模式
设计思想:假如能够预先传入一个 Socket 列表,如果列表中的 Socket 都没有数据,挂起进程,直到有一个 Socket 收到数据,唤醒进程。这种方法很直接,也是 Select 的设计思想
实现方法:操作系统实现的时候,将进程放入每个需要监听的socket等待队列中去,只要某个socket有数据了进程也就能够被唤醒(所谓唤起进程,就是将进程从所有的等待队列中移除,加入到cpu工作队列里面)
当进程 A 被唤醒后,它知道至少有一个 Socket 接收了数据。程序只需遍历一遍 Socket 列表,就可以得到就绪的 Socket
实现方法:操作系统实现的时候,将进程放入每个需要监听的socket等待队列中去,只要某个socket有数据了进程也就能够被唤醒(所谓唤起进程,就是将进程从所有的等待队列中移除,加入到cpu工作队列里面)
当进程 A 被唤醒后,它知道至少有一个 Socket 接收了数据。程序只需遍历一遍 Socket 列表,就可以得到就绪的 Socket
select存在的缺点:
- 每次调用 Select 都需要将进程加入到所有监视 Socket 的等待队列,每次唤醒都需要从每个队列中移除。这里涉及了两次遍历,而且每次都要将整个 FDS 列表传递给内核,有一定的开销(正是因为遍历操作开销大,出于效率的考量,才会规定 Select 的最大监视数量,默认只能监视 1024 个 Socket)
- 进程被唤醒后,程序并不知道哪些 Socket 收到数据,还需要遍历一次
poll模式
1.相比select, 把fd的bitMap变为了fd的链表,
2.fd的个数上限大于1024个, 但依旧会有用户态和内核态切换的性能问题.
3.依旧存在时间复杂度O(n)的问题, 提升性能不大
epoll模式(eventpoll)
Epoll 的设计思路
措施一:功能分离:Select 低效的原因之一是将“维护等待队列”和“阻塞进程”两个步骤合二为一
Epoll 拆分了功能:Epoll 将这两个操作分开,先用 epoll_ctl 维护等待队列,再调用 epoll_wait 阻塞进程
先用 epoll_create 创建一个 Epoll 对象 Epfd,再通过 epoll_ctl 将需要监视的 Socket 添加到 Epfd 中,最后调用 epoll_wait 等待数据
Epoll 拆分了功能:Epoll 将这两个操作分开,先用 epoll_ctl 维护等待队列,再调用 epoll_wait 阻塞进程
先用 epoll_create 创建一个 Epoll 对象 Epfd,再通过 epoll_ctl 将需要监视的 Socket 添加到 Epfd 中,最后调用 epoll_wait 等待数据
措施二:就绪列表:Select 低效的另一个原因在于程序不知道哪些 Socket 收到数据,只能一个个遍历。如果内核维护一个“就绪列表”,引用收到数据的 Socket,就能避免遍历
Epoll 的原理与工作流程
创建 Epoll 对象:eventpoll 对象也是文件系统中的一员,和 Socket 一样,它也会有等待队列,创建一个代表该 Epoll 的 eventpoll 对象是必须的,因为内核要维护“就绪列表”等数据,“就绪列表”可以作为 eventpoll 的成员
维护监视列表:创建 Epoll 对象后,可以用 epoll_ctl 添加或删除所要监听的 Socket
维护就绪列表:当 Socket 收到数据后,中断程序会给 eventpoll 的“就绪列表”添加 Socket 引用(内核会将 eventpoll 添加到Socket 的等待队列中,当 Socket 收到数据后,中断程序会操作 eventpoll 对象,而不是直接操作进程)
维护监视列表:创建 Epoll 对象后,可以用 epoll_ctl 添加或删除所要监听的 Socket
维护就绪列表:当 Socket 收到数据后,中断程序会给 eventpoll 的“就绪列表”添加 Socket 引用(内核会将 eventpoll 添加到Socket 的等待队列中,当 Socket 收到数据后,中断程序会操作 eventpoll 对象,而不是直接操作进程)
epoll使用到的数据结构
就绪列表的数据结构:【双向链表】
索引结构(监视的Socket列表):【红黑树】,通过接收的数据包的端口定位到socket
select跟poll都是直接将进程放入每个socket的等待队列中,而epoll则是放入eventpoll到每个socket的等待队列,然后进程放入eventpoll的等待队列,这样间接实现了功能。当某个socket收到数据的时候会操作eventpoll中介(并不会将epoll对象从socket的等待队列中移除),接着将存在事件的socket放入它的“就绪列表”rdlist中,我们的进程阻塞在eventpoll中的等待队列:如果“就绪列表”rdlist不为空则唤醒进程,为空则阻塞进程(唤醒进程后会从epoll的等待队列移除,做完事后再将进程放回epoll等待队列中,这里只会放到epoll一个上,为O(1))
epoll是个对象并不是个进程,中断程序操作这个对象,将有数据socket添加到epoll对象的就绪列表中,此时就绪列表不为空,epoll_wait立即返回,唤醒进程
结合上面说的设计思想讲,相当于使用了eventpoll中介来 “维护等待队列” ,由eventpoll来管理需要维护那些socket,从而避免重复移除socket跟添加socket(本身需监视socket列表基本不会变);通过eventpoll里的 “就绪列表” 来改变进场状态,进而实现阻塞与唤醒进程
epoll是个对象并不是个进程,中断程序操作这个对象,将有数据socket添加到epoll对象的就绪列表中,此时就绪列表不为空,epoll_wait立即返回,唤醒进程
结合上面说的设计思想讲,相当于使用了eventpoll中介来 “维护等待队列” ,由eventpoll来管理需要维护那些socket,从而避免重复移除socket跟添加socket(本身需监视socket列表基本不会变);通过eventpoll里的 “就绪列表” 来改变进场状态,进而实现阻塞与唤醒进程
三大核心组件: Channel(通道), Buffer(缓冲区),Selector(选择器)
channel 类似于流,每个 channel 对应一个 buffer缓冲区,buffer 底层就是个数组;
channel 会注册到 selector 上,由 selector 根据 channel 读写事件的发生将其交由某个空闲的线程处理
channel 会注册到 selector 上,由 selector 根据 channel 读写事件的发生将其交由某个空闲的线程处理
selector 可以对应一个或多个线程
NIO 的 Buffer 和 channel 都是既可以读也可以写
一开始会将ServerSocketChannel 注册到selector并申明对accept/əkˈsept/连接操作感兴趣。在代码中最外层的while循环selector.select();一直在轮训监听key(channel注册完会返回一个与该channel绑定的SelectionKey),当有客户端连接或者其他事件就会被selector感知到(当客户端来连接的时候,会找到ServerSocketChannel并发送accept事件给它,此时这个ServerSocketChannel正好设置了对accept事件感兴趣,因为selector一直在循环监听key也就相当监听channel,就感知到了,接着交给后面去执行),然后后面有个遍历selectedKeys去执行相关的操作,可能是read也可能是accept
无锁串行化设计思想
在大多数场景下,并行多线程处理可以提升系统的并发性能。但是,如果对于共享资源的并发访问处理不当,会带来严重的锁竞争,这最终会导致性能的下降。为了尽可能的避免锁竞争带来的性能损耗,可以通过串行化设计,即消息的处理尽可能在同一个线程内完成,期间不进行线程切换,这样就避免了多线程竞争和同步锁。
为了尽可能提升性能,Netty采用了串行无锁化设计,在IO线程内部进行串行操作,避免多线程竞争导致的性能下降。表面上看,串行化设计似乎CPU利用率不高,并发程度不够。但是,通过调整NIO线程池的线程参数,可以同时启动多个串行化的线程并行运行,这种局部无锁化的串行线程设计相比一个队列-多个工作线程模型性能更优。
Netty的NioEventLoop读取到消息之后,直接调用ChannelPipeline的fireChannelRead(Object msg),只要用户不主动切换线程,一直会由NioEventLoop调用到用户的Handler,期间不进行线程切换,这种串行化处理方式避免了多线程操作导致的锁的竞争,从性能角度看是最优的。
为了尽可能提升性能,Netty采用了串行无锁化设计,在IO线程内部进行串行操作,避免多线程竞争导致的性能下降。表面上看,串行化设计似乎CPU利用率不高,并发程度不够。但是,通过调整NIO线程池的线程参数,可以同时启动多个串行化的线程并行运行,这种局部无锁化的串行线程设计相比一个队列-多个工作线程模型性能更优。
Netty的NioEventLoop读取到消息之后,直接调用ChannelPipeline的fireChannelRead(Object msg),只要用户不主动切换线程,一直会由NioEventLoop调用到用户的Handler,期间不进行线程切换,这种串行化处理方式避免了多线程操作导致的锁的竞争,从性能角度看是最优的。
ByteBuf内存池重用设计
随着JVM虚拟机和JIT即时编译技术的发展,对象的分配和回收是个非常轻量级的工作。但是对于缓冲区Buffer(相当于一个内存块),情况却稍有不同,特别是对于堆外直接内存的分配和回收,是一件耗时的操作。为了尽量重用缓冲区,Netty提供了基于ByteBuf内存池的缓冲区重用机制。需要的时候直接从池子里获取ByteBuf使用即可,使用完毕之后就重新放回到池子里去。
问题整理
nio为什么说是同步非阻塞的?
NIO相对于BIO非阻塞的体现就在,BIO的后端线程需要阻塞等待客户端写数据(比如read方法),如果客户端不写数据线程就要阻塞,NIO把等待客户端操作的事情交给了大总管 selector,selector 负责轮询所有已注册的客户端,发现有事件发生了才转交给后端线程处理,后端线程不需要做任何阻塞等待,直接处理客户端事件的数据即可,处理完马上结束,或返回线程池供其他客户端事件继续使用。还有就是 channel 的读写是非阻塞的。
nio中空循环bug?
问题产生原因
使用IO复用,Linux下一般默认就是epoll,Java NIO在Linux下默认也是epoll机制,但是JDK中epoll的实现却是有漏洞的,其中最有名的java nio epoll bug就是即使是关注的select轮询事件返回数量为0,NIO照样不断的从select本应该阻塞的Selector.select()/Selector.select(timeout)中wake up出来,导致CPU 100%问题。
问题就是连接出现了RST,因为poll和epoll对于突然中断的连接socket会对返回的eventSet事件集合置为POLLHUP或者POLLERR,eventSet事件集合发生了变化,这就导致Selector会被唤醒,进而导致CPU 100%问题。根本原因就是JDK没有处理好这种情况,比如SelectionKey中就没定义有异常事件的类型。
nio epoll bug不是linux epoll的问题,而是JDK自己实现epoll时没有考虑这种情况,或者说因为其他系统不存在这个问题(java甩锅给到Linux),Linux出现这种情况的时候会置为POLLHUP或者POLLERR,而Java没有去处理这种异常情况,所以导致整个bug发生
问题就是连接出现了RST,因为poll和epoll对于突然中断的连接socket会对返回的eventSet事件集合置为POLLHUP或者POLLERR,eventSet事件集合发生了变化,这就导致Selector会被唤醒,进而导致CPU 100%问题。根本原因就是JDK没有处理好这种情况,比如SelectionKey中就没定义有异常事件的类型。
nio epoll bug不是linux epoll的问题,而是JDK自己实现epoll时没有考虑这种情况,或者说因为其他系统不存在这个问题(java甩锅给到Linux),Linux出现这种情况的时候会置为POLLHUP或者POLLERR,而Java没有去处理这种异常情况,所以导致整个bug发生
解决方案
一种是nio事件类型SelectionKey新加一种"错误"类型,比如针对linux epoll中的epollhup和epollerr,如果出现这种事件,建议程序直接close socket,但这种方式相对来说对于目前的nio SelectionKey改动有点大,因为SelectionKey的定义目前是针对所有jdk平台的;
还有一种是针对jdk nio 对epoll的封装中,对于epoll的epollhup和epollerr事件,epoll封装内部直接处理,比如close socket,但是这种方案也有一点尴尬的是,可能上层应用代码还保留有出现问题的socket引用,这时最好是应用程序能够感知这种情况来处理比较好。
当然模仿netty的处理方式也是可以的
Netty的解决办法
1.对Selector的select操作周期进行统计,每完成一次空的select操作进行一次计数,
2.若在某个周期内连续发生N次空轮询,则认为触发了epoll死循环bug。
3.重建Selector,判断是否是其他线程发起的重建请求,若不是则将原SocketChannel从旧的Selector上去除注册,重新注册到新的Selector上,并将原来的Selector关闭。
为什么选择 Netty?
Netty 是业界最流行的 NIO 框架之一,它的健壮性、功能、性能、可定制性和可扩展性在同
类框架中都是首屈一指的,它已经得到成百上千的商用项目验证
类框架中都是首屈一指的,它已经得到成百上千的商用项目验证
API 使用简单,开发门槛低;
功能强大,预置了多种编解码功能,支持多种主流协议;
定制能力强,可以通过 ChannelHandler 对通信框架进行灵活的扩展
性能高,通过与其它业界主流的 NIO 框架对比,Netty 的综合性能最优
社区活跃,版本迭代周期短,发现的 BUG 可以被及时修复,同时,更多的新功能会被
加入
加入
经历了大规模的商业应用考验,质量得到验证。在互联网、大数据、网络游戏、企业应
用、电信软件等众多行业得到成功商用,证明了它完全满足不同行业的商用标准
说说业务中,Netty 的使用场景?
1)作为 RPC 框架的网络通信工具:在分布式系统中,各个节点之间需要远程服务调用,高性能的 RPC 框架必不可少,Netty 作为异步高性能的通信框架,往往作为基础通信组件被这些 RPC 框架使用。典型的应用有:阿里分布式服务框架 Dubbo 的 RPC 框架使用 Dubbo 协议进行节点间通信,Dubbo 协议默认使用 Netty 作为基础通信组件,用于实现。各进程节点之间的内部通信。Rocketmq底层也是用的Netty作为基础通信组件。
2)实现一个即时通讯系统 :使用 Netty 我们可以实现一个可以聊天类似微信的即时通讯系统
3)实现消息推送系统 :市面上有很多消息推送系统都是基于 Netty 来做的
2)实现一个即时通讯系统 :使用 Netty 我们可以实现一个可以聊天类似微信的即时通讯系统
3)实现消息推送系统 :市面上有很多消息推送系统都是基于 Netty 来做的
说说Netty的线程模型?
线程模型图
模型解释
1) Netty 抽象出两组线程池BossGroup和WorkerGroup,BossGroup专门负责接收客户端的连接, WorkerGroup专门负责网络的读写
2) BossGroup和WorkerGroup类型都是NioEventLoopGroup
3) NioEventLoopGroup 相当于一个事件循环线程组, 这个组中含有多个事件循环线程 , 每一个事件循环线程是NioEventLoop
4) 每个NioEventLoop都有一个selector , 用于监听注册在其上的socketChannel的网络通讯
5) 每个Boss NioEventLoop线程内部循环执行的步骤有 3 步
- 处理accept事件 , 与client 建立连接 , 生成 NioSocketChannel
- 将NioSocketChannel注册到某个worker NIOEventLoop上的selector
- 处理任务队列的任务 , 即runAllTasks
6) 每个worker NIOEventLoop线程循环执行的步骤
- 轮询注册到自己selector上的所有NioSocketChannel 的read, write事件
- 处理 I/O 事件, 即read , write 事件, 在对应NioSocketChannel 处理业务
- runAllTasks处理任务队列TaskQueue的任务 ,一些耗时的业务处理一般可以放入TaskQueue中慢慢处理,这样不影响数据在 pipeline 中的流动处理
7) 每个worker NIOEventLoop处理NioSocketChannel业务时,会使用 pipeline (管道),管道中维护了很多 handler 处理器用来处理 channel 中的数据
什么是TCP 粘包/拆包
TCP粘包拆包是指发送方发送的若干包数据到接收方接收时粘成一包或某个数据包被拆开接收。如图所示,client发了两个数据包D1和D2,但是server端可能会收到如下几种情况的数据。出现这个情况的原因在于发送端使用了优化算法:将多次间隔小且数据量小的数据合并成一个包发送
为什么出现粘包现象
TCP 是面向连接的, 面向流的, 提供高可靠性服务。 收发两端(客户端和服务器端) 都要有成对的 socket,因此, 发送端为了将多个发给接收端的包, 更有效的发给对方, 使用了优化方法(Nagle 算法),将多次间隔较小且数据量小的数据, 合并成一个大的数据块, 然后进行封包。 这样做虽然提高了效率, 但是接收端就难于分辨出完整的数据包了, 因为面向流的通信是无消息保护边界的。
TCP粘包/拆包的解决办法
1)格式化数据:每条数据有固定的格式(开始符、结束符),这种方法简单易行,但选择开始符和结束符的时候一定要注意每条数据的内部一定不能出现开始符或结束符。netty中就有自带的编解码器可以直接用,比如LineBasedFrameDecoder就是每个数据包之间以换行符作为分隔,LineBasedFrameDecoder 的工作原理是它依次遍历 ByteBuf 中的可读字节,判断是否有换行符,然后进行相应的截取
2)发送长度:发送每条数据的时候,将数据的长度一并发送,比如可以选择每条数据的前4位是数据的长度,应用层处理时可以根据长度来判断每条数据的开始和结束。netty自带的解码器FixedLengthFrameDecoder: 固定长度解码器,它能够按照指定的长度对消息进行相应的拆包。当然我们也可以自定义实现,在我的笔记中就有案例
说说 Netty 的零拷贝
Netty的接收和发送ByteBuffer采用DIRECT BUFFERS,使用堆外直接内存进行Socket读写,不需要进行字节缓冲区的二次拷贝。如果使用传统的JVM堆内存(HEAP BUFFERS)进行Socket读写,JVM会将堆内存Buffer拷贝一份到直接内存中,然后才能写入Socket中。JVM堆内存的数据是不能直接写入Socket中的。相比于堆外直接内存,堆内内存消息在发送过程中多了一次缓冲区的内存拷贝。
使用直接内存的话,它不占用堆内存空间,减少了发生GC的可能,正是因为没有JVM直接帮助管理内存,容易发生内存溢出,而且如果你没有设置直接内存最大值它会把物理内存耗完的,当达到阈值的时候,调用system.gc来进行一次FULL GC,间接把那些没有被使用的直接内存回收掉。【然后到这他可能会问你直接内存什么时候回收怎么回收?】
Netty 内部执行流程
妈的,这鬼问题我还不如直接答线程模型的答案呢,搞不懂
妈的,这鬼问题我还不如直接答线程模型的答案呢,搞不懂
服务端流程
1、创建ServerBootStrap实例
2、创建并绑定好两个线程组bossGroup和workerGroup,bossGroup用来处理连接事件,workerGroup用来处理读写事件
3、当bossGroup监听到连接事件后,将连接channel绑定到workerGroup中的一个selector中去
4、workerGroup中的selector监听到channel的读写事件后,交给管道ChannelPipeline处理
5、 ChannelPipeline 中又维护了一个由 ChannelHandlerContext 组成的双向链表,并且每个 ChannelHandlerContext 中又关联着一个 ChannelHandler
6、其中会帮我们创建好编解码和其他基本的ChannelHandler,也就通过责任链模式执行了
2、创建并绑定好两个线程组bossGroup和workerGroup,bossGroup用来处理连接事件,workerGroup用来处理读写事件
3、当bossGroup监听到连接事件后,将连接channel绑定到workerGroup中的一个selector中去
4、workerGroup中的selector监听到channel的读写事件后,交给管道ChannelPipeline处理
5、 ChannelPipeline 中又维护了一个由 ChannelHandlerContext 组成的双向链表,并且每个 ChannelHandlerContext 中又关联着一个 ChannelHandler
6、其中会帮我们创建好编解码和其他基本的ChannelHandler,也就通过责任链模式执行了
客户端流程
1、创建BootStrap实例
2、创建处理客户端的线程组
3、创建客户端连接的NioSocketChannel
4、创建pipeline管道与ChannelHandler
Netty 重连实现
首先这个讲的是客户端的重连。然后就可以分为两种情况:client启动的时候需要重连、程序运行过程中断开需要重连
- 连接的时候,注册一个listener,当操作完成后,如果成功,设置好连接,如果失败,通过futureListener.channel().eventLoop().schedule()方法运行一个新的线程,10s后在线程中继续执行doConnect()方法
- 那如何在断线的时候触发doConnect()方法呢?答案就是handler的channelInactive方法,当 TCP 连接断开时, 会回调 channelInactive 方法,我们只需要在该方法里重新调用doConnect方法即可。
NioEventLoopGroup 默认的构造函数会起多少线程?
默认为cpu核数的两倍。至于为什么,可以简单理解cpu核数有多少并发就有多少,所以就算你设置的线程数再多也没用,反而因为cpu调度造成并发能力降低
TCP三次握手跟四次挥手?
三次握手
第一次握手:建立连接时,客户端发送syn包(syn=x)到服务器,并进入SYN_SENT状态,等待服务器确认;SYN:同步序列编号(Synchronize Sequence Numbers)。通俗讲就是客户端请求连接服务器
第二次握手:服务器收到syn包,必须确认客户的SYN(ack=x+1),同时自己也发送一个SYN包(syn=y),即SYN+ACK包,此时服务器进入SYN_RECV状态;通俗讲就是服务器接受客户端的连接请求,然后发送一个确认信息跟连接请求回去
第三次握手:客户端收到服务器的SYN+ACK包,向服务器发送确认包ACK(ack=y+1),此包发送完毕,客户端和服务器进入。通俗讲就是客户端接受服务端连接请求,发送确认信息回去
四次挥手
第一次:客户端请求断开FIN,seq=u,进入FIN-WAIT-1状态。通俗:客户端请求断开连接
第二次:服务器收到客户端的后,发出ACK=1确认标志和客户端的确认号ack=u+1,自己的序列号seq=v,进入CLOSE-WAIT状态。通俗:服务端确认客户端的断开请求,并告诉客户端我同意断开
第三次:客户端收到服务器确认结果后,进入FIN-WAIT-2状态。此时服务器发送断开FIN信号,确认标志ACK=1,确认序号ack=u+1,自己序号seq=w,服务器进入LAST-ACK(最后确认态)。通俗:服务端请求断开连接
第四次:客户端收到回复后,发送确认ACK=1,ack=w+1,自己的seq=u+1,客户端进入TIME-WAIT(时间等待)。客户端经过2个最长报文段寿命后,客户端CLOSE;服务器收到确认后,立刻进入CLOSE状态。通俗:客户端同意服务端断开请求,并告诉服务端我同意断开,服务端收到回复后立马断开,客户端经过2个最长报文段寿命后也断开
为什么要三次握手?
讲“三次握手”的目的是“为了防止已失效的连接请求报文段突然又传送到了服务端,因而产生错误”。怎么说呢?比如在第一次握手客户端请求连接服务端的时候请求丢失了(实际没有丢失,而是因为网络原因发的很慢),然后客户端又发送一个请求连接信息,此时其实存在两个请求连接服务端的信息,假设采用两次握手(客户端请求连接、服务端确认连接后直接开始连接了),当第一个消息在连接已经关闭后才到达服务端的话,服务端以为是客户端的新连接请求,然后服务端同意,但是此时客户端是没有这个连接请求的,这就白白浪费了服务端资源,搞得服务端认为新的连接已建立好,一直等待接收客户端的信息
为什么关闭的时候是四次握手?
那四次分手又是为何呢?TCP协议是一种面向连接的、可靠的、基于字节流的运输层通信协议。TCP是全双工模式,这就意味着,当客户端发出FIN报文段时,只是表示客户端已经没有数据要发送了,客户端告诉服务端,它的数据已经全部发送完毕了;但是,这个时候客户端还是可以接受来自服务端的数据;当服务端返回ACK报文段时,表示它已经知道客户端没有数据发送了,但是服务端还是可以发送数据到客户端的;当服务端也发送了FIN报文段时,这个时候就表示服务端也没有数据要发送了,就会告诉客户端,我也没有数据要发送了,之后彼此就会愉快的中断这次TCP连接。如果要正确的理解四次分手的原理,就需要了解四次分手过程中的状态变化。
为什么TIME_WAIT状态需要经过2MSL(最大报文段生存时间)才能返回到CLOSE状态?
虽然按道理,四个报文都发送完毕,我们可以直接进入CLOSE状态了,但是我们必须假象网络是不可靠的,有可以最后一个ACK丢失。所以TIME_WAIT状态就是用来重发可能丢失的ACK报文。Client端等待了2MSL后依然没有收到回复,则证明Server端已正常关闭,那好,我Client端也可以关闭连接了
TCP 长连接和短连接了解么
我们知道 TCP 在进行读写之前,server 与 client 之间必须提前建立一个连接。建立连接的过程,需要我们常说的三次握手,释放/关闭连接的话需要四次挥手。这个过程是比较消耗网络资源并且有时间延迟的
短连接说的就是 server 端 与 client 端建立连接之后,读写完成之后就关闭掉连接,如果下一次再要互相发送消息,就要重新连接。短连接的优点很明显,就是管理和实现都比较简单,缺点也很明显,每一次的读写都要建立连接必然会带来大量网络资源的消耗,并且连接的建立也需要耗费时间。
长连接说的就是 client 向 server 双方建立连接之后,即使 client 与 server 完成一次读写,它们之间的连接并不会主动关闭,后续的读写操作会继续使用这个连接。长连接的可以省去较多的 TCP 建立和关闭的操作,降低对网络资源的依赖,节约时间。对于频繁请求资源的客户来说,非常适用长连接。
为什么需要心跳机制?Netty 中心跳机制了解么?
在 TCP 保持长连接的过程中,可能会出现断网等网络异常出现,异常发生的时候, client 与 server 之间如果没有交互的话,它们是无法发现对方已经掉线的。为了解决这个问题, 我们就需要引入 心跳机制
心跳机制的工作原理是: 在 client 与 server 之间在一定时间内没有数据交互时, 即处于 idle 状态时, 客户端或服务器就会发送一个特殊的数据包给对方, 当接收方收到这个数据报文后, 也立即发送一个特殊的数据报文, 回应发送方, 此即一个 PING-PONG 交互。所以, 当某一端收到心跳消息后, 就知道了对方仍然在线, 这就确保 TCP 连接的有效性.
TCP 实际上自带的就有长连接选项,本身是也有心跳包机制,也就是 TCP 的选项:SO_KEEPALIVE。但是,TCP 协议层面的长连接灵活性不够。所以,一般情况下我们都是在应用层协议上实现自定义心跳机制的,也就是在 Netty 层面通过编码实现。通过 Netty 实现心跳机制的话,核心类是 IdleStateHandler 。
IdleStateHandler的readerIdleTime参数指定超过3秒还没收到客户端的连接,会触发IdleStateEvent事件并且交给下一个handler处理,下一个handler必须实现userEventTriggered方法处理对应事件,在这个方法中根据我们业务处理“读空闲”、“写空闲、“读写空闲””,比如说“读空闲”达到多少次后就关闭这个长连接之类的,具体在笔记中有实现案例
readerIdleTimeSeconds: 读超时. 即当在指定的时间间隔内没有从 Channel 读取到数据时, 会触发一个 READER_IDLE 的 IdleStateEvent 事件.
writerIdleTimeSeconds: 写超时. 即当在指定的时间间隔内没有数据写入到 Channel 时, 会触发一个 WRITER_IDLE 的 IdleStateEvent 事件.
allIdleTimeSeconds: 读/写超时. 即当在指定的时间间隔内没有读或写操作时, 会触发一个 ALL_IDLE 的 IdleStateEvent 事件
readerIdleTimeSeconds: 读超时. 即当在指定的时间间隔内没有从 Channel 读取到数据时, 会触发一个 READER_IDLE 的 IdleStateEvent 事件.
writerIdleTimeSeconds: 写超时. 即当在指定的时间间隔内没有数据写入到 Channel 时, 会触发一个 WRITER_IDLE 的 IdleStateEvent 事件.
allIdleTimeSeconds: 读/写超时. 即当在指定的时间间隔内没有读或写操作时, 会触发一个 ALL_IDLE 的 IdleStateEvent 事件
jvm
JVM类加载机制、双亲委派
类加载
类加载机制
虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制
类的生命周期
1、加载(Loading)
加载:“加载”是“类加载”(Class Loading)过程的一个阶段,在加载阶段,虚拟机需要完成以下3件事情:
- 通过一个类的全限定名来获取定义此类的二进制字节流
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
2、验证(Verification)
验证:校验字节码文件的正确性
3、准备(Preparation)
准备:准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。
- 这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中;
- 这里所说的初始值“通常情况”下是数据类型的零值,比如private static int value = 123;变量value在准备阶段过后的初始值为0而不是123。
- 如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量value就会被初始化为ConstantValue属性所指定的值,比如private static final int value = 123;
4、解析(Resolution)
解析:解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程,该阶段会把一些静态方法(符号引用,比如main()方法)替换为指向数据所存内存的指针或句柄等(直接引用),这是所谓的静态链接过程(类加载期间完成),动态链接是在程序运行期间完成的将符号引用替换为直接引用。
- 符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。
- 直接引用(Direct References):直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在。
class常量池
- 字面量
- 符号引用
Class常量池可以理解为是Class文件中的资源仓库。 Class文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(constant pool table),用于存放编译期生成的各种字面量(Literal)和符号引用(Symbolic References)【就是class二进制文件的内容有部分是常量池】
字面量:字面量就是指由字母、数字等构成的字符串或者数值常量。如:int a=1;1就是字面量
符号引用:符号引用是编译原理中的概念,是相对于直接引用来说的。主要包括了以下三类常量:
比如:int a=1;a就是字段的符号引用,=就是字段的描述符,他们都是符号引用
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
比如:int a=1;a就是字段的符号引用,=就是字段的描述符,他们都是符号引用
5、初始化(Initialization)
初始化:对类的静态变量初始化为指定的值,执行静态代码块
6、使用(Using)
7、卸载(Unloading)
注意:主类在运行过程中如果使用到其它类,会逐步加载这些类。jar包或war包里的类不是一次性全部加载的,是使用到时才加载。
类加载器
引导类加载器:负责加载支撑JVM运行的位于JRE的lib目录下的核心类库,比如rt.jar、charsets.jar等
扩展类加载器:负责加载支撑JVM运行的位于JRE的lib目录下的ext扩展目录中的JAR类包,父类加载器为引导类加载器,但是由于引导类为c++对象,所以父类为空
应用程序类加载器:负责加载ClassPath路径下的类包,主要就是加载你自己写的那些类;父类加载器为扩展类加载器
自定义加载器:负责加载用户自定义路径下的类包。父类加载器为应用程序加载器
类加载器初始化过程
1、首先通过java命令执行java程序的时候,会调用jvm.dll文件创建jvm虚拟机
2、创建一个引导类加载器实例
3、C++会调用java代码创建JVM启动器实例sun.misc.Launcher /laːntʃər/。sun.misc.Launcher初始化使用单例模式设计,保证一个JVM虚拟机内只有一个sun.misc.Launcher实例。
4、在Launcher构造方法内部,其创建了两个类加载器,分别是sun.misc.Launcher.ExtClassLoader(扩展类加载器)和sun.misc.Launcher.AppClassLoader(应用类加载器)。
5、JVM默认使用Launcher的getClassLoader()方法返回的类加载器AppClassLoader的实例加载我们的应用程序。
2、创建一个引导类加载器实例
3、C++会调用java代码创建JVM启动器实例sun.misc.Launcher /laːntʃər/。sun.misc.Launcher初始化使用单例模式设计,保证一个JVM虚拟机内只有一个sun.misc.Launcher实例。
4、在Launcher构造方法内部,其创建了两个类加载器,分别是sun.misc.Launcher.ExtClassLoader(扩展类加载器)和sun.misc.Launcher.AppClassLoader(应用类加载器)。
5、JVM默认使用Launcher的getClassLoader()方法返回的类加载器AppClassLoader的实例加载我们的应用程序。
如何自定义类加载器
自定义类加载器只需要继承 java.lang.ClassLoader 类,该类有两个核心方法,一个是loadClass(String, boolean),实现了双亲委派机制,还有一个方法是findClass(默认实现是空方法),所以我们自定义类加载器主要是重写findClass方法。而findClass方法中最重要的就是defineClass(name, res);也就是真正加载类的方法
案例讲解:首先复制User.class到D:/test/test也存在User.class文件,我们如果classpath下面存在User.class这个文件的时候,首先代码写的是先app加载,发现app中找到了,所以就返回了。如果删除classpath下面存在User.class,就是由我们自定义类加载器MyClassLoader加载的
双亲委派机制
什么是双亲委派机制
双亲委派机制说简单点就是,先找父亲加载,不行再由儿子自己加载
比如我们的Math类,最先会找应用程序类加载器加载,应用程序类加载器会先委托扩展类加载器加载(这里不要想着Math不就是由app类加载器加载的吗,干嘛还要往上委托,下面会分析),扩展类加载器再委托引导类加载器,顶层引导类加载器在自己的类加载路径里找了半天没找到Math类,则向下退回加载Math类的请求,扩展类加载器收到回复就自己加载,在自己的类加载路径里找了半天也没找到Math类,又向下退回Math类的加载请求给应用程序类加载器,应用程序类加载器于是在自己的类加载路径里找Math类,结果找到了就自己加载了
从AppClassLoader源码看双亲委派机制,查看笔记
为什么要设计双亲委派机制?
沙箱安全机制:自己写的java.lang.String.class类不会被加载,这样便可以防止核心API库被随意篡改
避免类的重复加载:当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次,保证被加载类的唯一性
全盘负责委托机制
“全盘负责”是指当一个ClassLoder装载一个类时,除非显式的 去指定使用另外一个ClassLoder,不然该类所依赖及引用的类也由这个ClassLoder载入。
如何打破双亲委派机制
双亲委派机制在loadClass方法中实现,如果要打破双亲委派,我们只要重写loadClass方法即可,直接调用自己的findClass方法即可(ps:在父类的findClass方法中会去类加载器的资源目录查找)
使用打破双亲委派机制的自定义类加载器加载我们自己写的java.lang.Long测试
- 具体可以查看笔记,挺有意思的
classpath下面存在Long.class这个文件且D:/test/java/lang不存在Long.class:如果还有双亲委派,那自然能够找到,但是我这里打破了所以报找不到类异常
D:/test/java/lang存在Long.class:直接报禁止包名称的异常,为啥没报Object找不到异常呢,因为它首先都会找Long,一看到是Long就报错了,根本没走到加载Object的时候
如果我们将main方法改成加载User呢
classpath下面存在User.class这个文件且D:/test/test存在User.class,按理是不是应该找得到?结果却说找不到Object
- 原因:因为我们的User的父类是Object,所以也会先加载Object
如果说将Object.class 复制到D盘下,就又会报禁止包名称的异常
Tomcat打破双亲委派机制
为何tomcat需要打破双亲委派
1. 一个web容器可能需要部署两个应用程序,不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求同一个类库在同一个服务器只有一份,因此要保证每个应用程序的类库都是独立的,保证相互隔离。因为他们的类路径权限名是一样的,在加载的时候无法区分
2. 部署在同一个web容器中相同的类库相同的版本可以共享。否则,如果服务器有10个应用程序,那么要有10份相同的类库加载进虚拟机。
3. tomcat容器也有自己依赖的类库,不能与应用程序的类库混淆。基于安全考虑,应该让容器的类库和程序的类库隔离开来。
4. web容器要支持jsp的修改,我们知道,jsp 文件最终也是要编译成class文件才能在虚拟机中运行,但程序运行后修改jsp已经是司空见惯的事情, web容器需要支持 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版本;(我们自定义的类加载器不就实现了吗,你传入自己的路径,只加载自己传入路径的类)
tomcat 这种类加载机制违背了java 推荐的双亲委派模型了吗?
打破了。每个webappClassLoader加载自己的目录下的class文件,不会传递给父类加载器,打破了双亲委派机制。
tomcat的JasperLoader热加载大概原理
后台启动线程监听jsp文件变化,如果变化了找到该jsp对应的servlet类的加载器引用(gcroot),重新生成新的JasperLoader加载器赋值给引用,然后加载新的jsp对应的servlet类,之前的那个加载器因为没有gcroot引用了,下一次gc的时候会被销毁。
注意:在类加载器中区分两个类是否为同一个的时候,除了判断全限名相同外还会判断类加载器是否相同
jvm内存模型
内存模型
程序计数器
程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。
每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存
如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)。此内存区域是唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域。
Java虚拟机栈
Java虚拟机栈(Java Virtual Machine Stack)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧[1](Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程
局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)
本地方法栈
本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。
Java堆
Java堆(Java Heap)是虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java世界里“几乎”所有的对象实例都在这里分配内存。在《Java虚拟机规范》中对Java堆的描述是:“所有的对象实例以及数组都应当在堆上分配 [1] ”,而这里笔者写的“几乎”是指从实现角度来看,随着Java语言的发展,现在已经能看到些许迹象表明日后可能出现值类型的支持,即使只考虑现在,由于即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配、标量替换 [2] 优化手段已经导致一些微妙的变化悄然发生,所以说Java对象实例都分配在堆上也渐渐变得不是那么绝对了
堆是GC的主要发生区域,由于现代垃圾收集器大部分都是基于分代收集理论设计的,所以Java堆分为新生代、老年代、永久代、Eden、Survivor,但是这些区域划分仅仅是一部分垃圾收集器的共同特性或者说设计风格而已,而非某个Java虚拟机具体实现的固有内存布局,更不是《Java虚拟机规范》里对Java堆的进一步细致划分。在十年之前(以G1收集器的出现为分界),作为业界绝对主流的HotSpot虚拟机,它内部的垃圾收集器全部都基于“经典分代” [3] 来设计,需要新生代、老年代收集器搭配才能工作,在这种背景下,上述说法还算是不会产生太大歧义。但是到了今天,垃圾收集器技术与十年前已不可同日而语,HotSpot里面也出现了不采用分代设计的新垃圾收集器,再按照上面的提法就有很多需要商榷的地方了
如果从分配内存的角度看,所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)
方法区(元空间)
方法区(元空间)
方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然《Java虚拟机规范》中把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作“非堆”(Non-Heap),目的是与Java堆区分开来
在JDK8之前很多人把方法区描述为“永久代”,这个其实不等价的,只是当时HotSpot虚拟机设计团队选择把收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区而已,这样使得HotSpot的垃圾收集器能够像管理Java堆一样管理这部分内存,省去专门为方法区编写内存管理代码的工作。在JDK 6的时候HotSpot开发团队就有放弃永久代,逐步改为采用本地内存(Native Memory)来实现方法区的计划了 [1] ,到了JDK 7的HotSpot,已经把原本放在永久代的字符串常量池、静态变量等移出,而到了JDK 8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Meta-space)来代替,把JDK 7中永久代还剩余的内容(主要是类型信息)全部移到元空间中
运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中
运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是说,并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法
Jdk1.6及之前: 有永久代, 运行时常量池在永久代,运行时常量池包含字符串常量池
Jdk1.7:有永久代,但已经逐步“去永久代”,字符串常量池、静态变量从永久代里的运行时常量池分离到堆里
Jdk1.8及之后: 无永久代,运行时常量池在元空间,字符串常量池里依然在堆里
Jdk1.7:有永久代,但已经逐步“去永久代”,字符串常量池、静态变量从永久代里的运行时常量池分离到堆里
Jdk1.8及之后: 无永久代,运行时常量池在元空间,字符串常量池里依然在堆里
直接内存
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现
在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。(零拷贝),一般服务器管理员配置虚拟机参数时,会根据实际内存去设置-Xmx等参数信息,但经常忽略掉直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常
什么时候回收?
在回收DirectByteBuffer对象的同时且会回收其占用的堆外内存
当然也可以手动回收,就是由开发手动调用DirectByteBuffer的cleaner的clean方法来释放空间。由于cleaner是private反问权限,所以自然想到使用反射来实现。
从类加载到对象的创建在虚拟机运行时数据区走向图
JVM内存参数
堆
-Xmx:堆的最大值,超出会报OutOfMemoryError
-Xms:堆的最小值(初始化大小),将堆的最小值-Xms参数与最大值-Xmx参数设置为一样即可避免堆自动扩展
-Xmn:指年轻代大小,不设置默认为堆的1/3大小
虚拟机栈
-Xss:是指设定每个线程的堆栈大小,默认1M,如果超出了会报栈溢出(StackOverFlowError),-Xss设置越小,一个线程栈里能分配的栈帧就越少,但是对JVM整体来说能开启的线程数会更多
元空间
-XX:MaxMetaspaceSize: 设置元空间最大值, 默认是-1, 即不限制, 或者说只受限于本地内存大小。
-XX:MetaspaceSize: 指定元空间触发Fullgc的初始阈值(元空间无固定初始大小), 以字节为单位,默认是21M,达到该值就会触发full gc进行类型卸载, 同时收集器会对该值进行调整: 如果释放了大量的空间, 就适当降低该值; 如果释放了很少的空间, 那么在不超过-XX:MaxMetaspaceSize(如果设置了的话) 的情况下, 适当提高该值。这个跟早期jdk版本的-XX:PermSize参数意思不一样,-XX:PermSize代表永久代的初始容量。
【建议在JVM参数中将MetaspaceSize和MaxMetaspaceSize设置成一样的值】
【建议在JVM参数中将MetaspaceSize和MaxMetaspaceSize设置成一样的值】
案例:java -Xms2048M -Xmx2048M -Xmn1024M -Xss512K -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -jar microservice-eureka-server.jar
结合c++猜测个人理解
之前的方法区存放着类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据,也就是类信息放到方法区中的运行时常量池中、静态变量属于类变量,在之前字符串常量池是在运行时常量池中,1.7后分离开了,在jdk1.7将字符串常量池、静态变量分别移出到堆中,1.8后把剩余的类型信息移到元空间中,也就是运行时常量池是在元空间中
在jvm中可能是通过几个变量去控制各自区域的内存大小,站在c++实现角度说,所有的java对象其实都是通过c++一系列的oop对象实现,比如java中各种类信息,比如java方法的符号引用在c++中可能methodOop对象,各种oop共同组成了我们的java对象,在gc的时候,就是会去指定的区域或者说指定的oop对象里面释放内存
说到底虚拟机中的内存布局都是看c++中oop对象怎么实现,然后我们根据这种逻辑划分除了栈、堆这一系列的jvm内存模型,在我看来这些概念随着java的发展已经不能说的那么绝对了,有的甚至没有之前的逻辑概念了,就好像一些垃圾收集器的上面,原本存在Eden区或者各种分代区域,这些逻辑概念都不存在了,所以不用过于纠结,这些都是我的猜测与理解,至于真实是什么样可能需要去深入看jvm的源码了,目前我也没这个能力
结合java内存模型理解:java线程是内核线程模型,空间是由操作系统帮我们开辟的,而堆是jvm申请的
结合java内存模型理解:java线程是内核线程模型,空间是由操作系统帮我们开辟的,而堆是jvm申请的
JVM对象创建与内存分配机制深度剖析
java对象的创建
(不包括数组和Class对象)
(不包括数组和Class对象)
1、类加载检查
虚拟机遇到一条new指令时,首先将去检查这个指令的参数(无参构造的话只有一个new指令,参数为自己类,有参的话有多少个就有多少个new、dup、invokespecial指令)是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
2、分配内存
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把 一块确定大小的内存从Java堆中划分出来。
划分内存的方法
“指针碰撞”(Bump the Pointer)(默认用指针碰撞)
如果Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离
“空闲列表”(Free List)
如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例, 并更新列表上的记录
内存分配并发问题
在并发情况下,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时对同一个指针来分配内存的情况。
解决并发问题的方法
CAS(compare and swap):虚拟机采用CAS配上失败重试的方式保证更新操作的原子性来对分配内存空间的动作进行同步处理。
本地线程分配缓冲(Thread Local Allocation Buffer,TLAB):把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存。如果分配的内存不够了就接着使用上面那种,通过-XX:+/-UseTLAB参数来设定虚拟机是否使用TLAB(JVM会默认开启-XX:+UseTLAB),-XX:TLABSize 指定TLAB大小。
3、初始化
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头), 如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值
4、设置对象头
初始化零值之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头Object Header之中。jvm通过对象头中的klass指针去找到放在方法区中的类元信息
对象存储布局
对象头(Header)
HotSpot虚拟机的对象头包括两部分信息,第一部分(Mark word)用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。对象头的另外一部分是类型指针(klass pointer),即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
如果对象是数组类型,则需要三个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说,Mark Word会随着程序的运行发生变化
实例数据(Instance Data)
接下来实例数据部分是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。这部分的存储顺序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle参数)和字段在Java源码中定义顺序的影响。
HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs),从以上默认的分配策略中可以看到,相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果HotSpot虚拟机的+XX:CompactFields参数值为true(默认就为true),那子类之中较窄的变量也允许插入父类变量的空隙之中,以节省出一点点空间
HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs),从以上默认的分配策略中可以看到,相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果HotSpot虚拟机的+XX:CompactFields参数值为true(默认就为true),那子类之中较窄的变量也允许插入父类变量的空隙之中,以节省出一点点空间
对齐填充(Padding)
对象的第三部分是对齐填充,这并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍(站在计算机组成原理的角度上讲,64位操作系统采用8字节的寻址是最快的,所以在不满足的时候就对齐8的整数倍去填充),换句话说就是任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好是8字节的倍数(1倍或者2倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全
5、执行<init>方法
执行<init>方法,即对象按照程序员的意愿进行初始化。对应到语言层面上讲,就是为属性赋值(注意,这与上面的赋零值不同,这是由程序员赋的值),和执行构造方法。这个init方法不要想着就是构造方法,要是构造方法为空构造呢?所以不要这么想
对象大小与指针压缩
什么是java对象的指针压缩?
1.jdk1.6 update14开始,在64bit操作系统中,JVM支持指针压缩
2.jvm配置参数:UseCompressedOops,compressed--压缩、oop(ordinary object pointer)--对象指针
3.启用指针压缩:-XX:+UseCompressedOops(默认开启),禁止指针压缩:-XX:-UseCompressedOops
2.jvm配置参数:UseCompressedOops,compressed--压缩、oop(ordinary object pointer)--对象指针
3.启用指针压缩:-XX:+UseCompressedOops(默认开启),禁止指针压缩:-XX:-UseCompressedOops
为什么要进行指针压缩?
在 Java 中,“指针压缩”(Compressed Oops,其中Oops代表Ordinary Object Pointers)是 HotSpot JVM 的一个功能,它允许64位JVM在不牺牲太多性能的情况下利用更小的内存空间存储对象引用。
在 64 位架构上,原生的指针(地址)大小为 64 位(8 字节),使得可以访问非常大的内存空间(理论上高达 16 Exabytes,1 EB = 1,000,000 TB)。然而,对于大多数应用程序而言,它们实际上并不需要如此巨大的地址空间。默认情况下,即使是一个中等规模的应用程序,每个对象引用都使用 8 字节存储也显得极为浪费。这会导致以下问题:
在 64 位架构上,原生的指针(地址)大小为 64 位(8 字节),使得可以访问非常大的内存空间(理论上高达 16 Exabytes,1 EB = 1,000,000 TB)。然而,对于大多数应用程序而言,它们实际上并不需要如此巨大的地址空间。默认情况下,即使是一个中等规模的应用程序,每个对象引用都使用 8 字节存储也显得极为浪费。这会导致以下问题:
- 内存使用增加:每个指针占用的空间加倍将导致每个对象以及类的元空间占用的总内存显著增加。
- 缓存效率降低:更大的指针意味着更少的指针能够放进 CPU 缓存中,从而降低缓存的效率,因为缓存行能够存储的对象指针数量减少了。
- 带宽消耗增加:传输大小增加的指针需要数据总线和内存总线更多的带宽,这在内存密集型应用程序中可能成为性能瓶颈。
在jvm中,32位地址最大支持4G内存(2的32次方),可以通过对对象指针的存入堆内存时压缩编码、取出到cpu寄存器后解码方式进行优化
简单理解:对象头使用的8byte的整数倍对齐,然后在堆中表示的是压缩的编码,java堆内存进行8byte划分,对象指针就不用存对象真实的64bit地址了,而是存一个堆中的映射地址编号,这样就使用1byte表示64bit地址,也就是可以表示32G的内存空间,这也是为什么jvm内存为什么不建议超过32G的原因
堆内存小于4G时,不需要启用指针压缩,jvm会直接去除高32位地址,即使用低虚拟地址空间
堆内存大于32G时,压缩指针会失效,会强制使用64位(即8字节)来对java对象寻址,这就会出现1的问题,所以堆内存不要大于32G为好
关于对齐填充:对于大部分处理器,对象以8字节整数倍来对齐填充都是最高效的存取方式。
对象内存分配
对象栈上分配
我们通过JVM内存分配可以知道JAVA中的对象都是在堆上进行分配,当对象没有被引用的时候,需要依靠GC进行回收内存,如果对象数量较多的时候,会给GC带来较大压力,也间接影响了应用的性能。为了减少临时对象在堆内分配的数量,JVM通过逃逸分析确定该对象不会被外部访问。如果不会可以将该对象在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。
对象逃逸分析:就是分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中
JVM对于这种情况可以通过开启逃逸分析参数(-XX:+DoEscapeAnalysis)来优化对象内存分配位置,使其通过标量替换优先分配在栈上(栈上分配),JDK7之后默认开启逃逸分析,如果要关闭使用参数(-XX:-DoEscapeAnalysis)
标量替换
通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该对象成员变量分解若干个被这个方法使用的成员变量所代替,这些代替的成员变量在栈帧或寄存器上分配空间,这样就不会因为没有一大块连续空间导致对象内存不够分配。开启标量替换参数(-XX:+EliminateAllocations),JDK7之后默认开启
标量与聚合量
标量即不可被进一步分解的量,而JAVA的基本数据类型就是标量(如:int,long等基本数据类型以及reference类型等)
标量的对立就是可以被进一步分解的量,而这种量称之为聚合量。而在JAVA中对象就是可以被进一步分解的聚合量。
总结:栈上分配是通过标量替换进行的,而能否在栈上分配则依赖于逃逸分析,能逃逸才在栈上分配
对象在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两个收集器下有效。
为什么要这样呢?
为了避免为大对象分配内存时的复制操作而降低效率。
长期存活的对象将进入老年代
既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。
如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为1。对象在 Survivor 中每熬过一次 MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,CMS收集器默认6岁,不同的垃圾收集器会略微有点不同)(最大为15岁,因为Mark word中分代年龄为4bit),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。
如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为1。对象在 Survivor 中每熬过一次 MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,CMS收集器默认6岁,不同的垃圾收集器会略微有点不同)(最大为15岁,因为Mark word中分代年龄为4bit),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。
对象动态年龄判断
当前放对象的Survivor区域里(放对象的那块s区),一批对象的总大小大于这块Survivor区域内存大小的50%(-XX:TargetSurvivorRatio可以指定),那么此时大于等于这批对象年龄最大值的对象,就可以直接进入老年代了,例如Survivor区域里现在有一批对象,年龄1+年龄2+年龄n的多个年龄对象总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代。这个规则其实是希望那些可能是长期存活的对象,尽早进入老年代。对象动态年龄判断机制一般是在minor gc之后触发的
总体表征就是,年龄从小到大进行累加,当加入某个年龄段后,累加和超过survivor区域*TargetSurvivorRatio的时候,就从这个年龄段网上的年龄的对象进行晋升。并不是相同年龄的对象和来判断
老年代空间分配担保机制
字面上就能理解,就是老年代为了能有足够的内存迎接下一次进入老年代的对象而设计的机制
年轻代每次minor gc之前JVM都会计算下老年代剩余可用空间,如果这个可用空间小于年轻代里现有的所有对象大小之和(包括垃圾对象),就会看一个“-XX:-HandlePromotionFailure”(jdk1.8默认就设置了)的参数是否设置了,如果有这个参数,就会看看老年代的可用内存大小,是否大于之前每一次minor gc后进入老年代的对象的平均大小。如果上一步结果是小于或者之前说的参数没有设置,那么就会触发一次Full gc,对老年代和年轻代一起回收一次垃圾,如果回收完还是没有足够空间存放新的对象就会发生"OOM"。当然,如果minor gc之后剩余存活的需要挪动到老年代的对象大小还是大于老年代可用空间,那么也会触发full gc,full gc完之后如果还是没有空间放minor gc之后的存活对象,则也会发生“OOM”
这样给老年代的担保机制,避免了minor gc后剩余的空间还是无法放入老年代,减少了一次minor gc,而且既然都小于了那后面很容易占满了
对象内存回收
如何判断无用对象
引用计数法
给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加1;当引用失效,计数器就减1;任何时候计数器为0的对象就是不可能再被使用的
这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。
- 所谓对象之间的相互引用问题,如此上图代码:除了对象objA 和 objB 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为0,于是引用计数算法无法通知 GC 回收器回收他们
可达性分析算法
将“GC Roots” 对象作为起点,从这些节点开始向下搜索引用的对象,找到的对象都标记为非垃圾对象,其余未标记的对象都是垃圾对象
- GC Roots根节点:线程栈的本地变量、静态变量、本地方法栈的变量等等
常见引用类型
强引用:普通的变量引用
- public static User user = new User();
软引用:将对象用SoftReference软引用类型的对象包裹,正常情况不会被回收,但是GC做完后发现释放不出空间存放新的对象,则会把这些软引用的对象回收掉。软引用可用来实现内存敏感的高速缓存
- public static java.lang.ref.SoftReference<User> user = new SoftReference<User>(new User());
弱引用:将对象用WeakReference软引用类型的对象包裹,弱引用跟没引用差不多,GC会直接回收掉,很少用
- public static java.lang.ref.WeakReference<User> user = new WeakReference<User>(new User());
虚引用:虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系,几乎不用
finalize()方法最终判定对象是否存活
即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历再次标记过程
- 标记的前提是对象在进行可达性分析后发现没有与GC Roots相连接的引用链
两次标记过程
1. 第一次标记并进行一次筛选。
- 筛选的条件是此对象是否有必要执行finalize()方法。
- 当对象没有覆盖finalize方法,对象将直接被回收
2. 第二次标记
- 如果这个对象覆盖了finalize方法,finalize方法是对象脱逃死亡命运的最后一次机会,如果对象要在finalize()中成功拯救自己,只要重新与引用链上的任何的一个对象建立关联即可,如把自己赋值给某个类变量或对象的成员变量,那在第二次标记时它将移除出“即将回收”的集合。如果对象这时候还没逃脱,那基本上就真的被回收了
注意:一个对象的finalize()方法只会被执行一次,也就是说通过调用finalize方法自我救命的机会就一次
- finalize()方法的运行代价高昂, 不确定性大, 无法保证各个对象的调用顺序, 如今已被官方明确声明为不推荐使用的语法。 有些资料描述它适合做“关闭外部资源”之类的清理性工作, 这完全是对finalize()方法用途的一种自我安慰。 finalize()能做的所有工作, 使用try-finally或者其他方式都可以做得更好、更及时, 所以建议大家完全可以忘掉Java语言里面的这个方法
如何判断一个类是无用的类
- 方法区主要回收的是无用的类
类需要同时满足下面3个条件才能算是 “无用的类” :
- 该类所有的对象实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
- 加载该类的 ClassLoader 已经被回收。
- 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
日均百万级订单交易系统如何设置JVM参数
分析:前面得出电商亿级点击量到后台每秒会有60M的对象产生(一个下单操作转眼结束也就是60M对象瞬间变成垃圾对象,但因为没有达到Eden区回收的阈值也就不会GC)。比如说我们设置了上图大小:在运行14s的时候就会占满Eden区,也就是会进行Minor GC,GC的时候来的对象就会尝试往Survivor区放,发现Survivor区放不下(是达到Survivor的多少比例)然后就会直接进入老年代。就这样每次在minor gc的时候就有对象直接进入老年代,过不了多久一下子就占满了老年代空间,触发full gc.....
那我们该怎么办呢?我们可以设置Eden区跟survivor区的空间大些,比如设置年轻代为1600M,两个survivor区每个200M,此时我们再来分析:我们27s左右就会占满我们的年轻代(也就是Eden区),触发minor gc,gc的时候产生的对象往survivor区放,发现能放下,也就是我们回收后剩余的对象跟gc时产生的对象会放在survivor区中,这样一直下去都基本不会有对象丢到老年代,也就是不会发生full gc。
阿里面试题:能否对jvm调优,让其几乎不发生full gc?
将Eden区跟survivor尽量的分大些避免对象因为内存不足放到full gc中去,只有真正不会被回收的才去到老年代
结论:通过上面这些内容介绍,大家应该对JVM优化有些概念了,就是尽可能让对象都在新生代里分配和回收,尽量别让太多对象频繁进入老年代,避免频繁对老年代进行垃圾回收,同时给系统充足的内存大小,避免新生代频繁的进行垃圾回收
对于对象年龄应该为多少才移动到老年代比较合适,本例中一次minor gc要间隔二三十秒,大多数对象一般在几秒内就会变为垃圾,完全可以将默认的15岁改小一点,比如改为5,那么意味着对象要经过5次minor gc才会进入老年代,整个时间也有一两分钟了,如果对象这么长时间都没被回收,完全可以认为这些对象是会存活的比较长的对象,可以移动到老年代,而不是继续一直占用survivor区空间
对于多大的对象直接进入老年代(参数-XX:PretenureSizeThreshold),这个一般可以结合你自己系统看下有没有什么大对象生成,预估下大对象的大小,一般来说设置为1M就差不多了,很少有超过1M的大对象,这些对象一般就是你系统初始化分配的缓存对象,比如大的缓存List,Map之类的对象
对于JDK8默认的垃圾回收器是-XX:+UseParallelGC(年轻代)和-XX:+UseParallelOldGC(老年代),如果内存较大(超过4个G,只是经验值),系统对停顿时间比较敏感,我们可以使用ParNew+CMS(-XX:+UseParNewGC -XX:+UseConcMarkSweepGC)
对于老年代CMS的参数如何设置我们可以思考下,首先我们想下当前这个系统有哪些对象可能会长期存活躲过5次以上minor gc最终进入老年代。无非就是那些Spring容器里的Bean,线程池对象,一些初始化缓存数据对象等,这些加起来充其量也就几十MB。还有就是某次minor gc完了之后还有超过一两百M的对象存活,那么就会直接进入老年代,比如突然某一秒瞬间要处理五六百单,那么每秒生成的对象可能有一百多M,再加上整个系统可能压力剧增,一个订单要好几秒才能处理完,下一秒可能又有很多订单过来
我们可以估算下大概每隔五六分钟出现一次这样的情况,那么大概半小时到一小时之间就可能因为老年代满了触发一次Full GC,Full GC的触发条件还有我们之前说过的老年代空间分配担保机制,历次的minor gc挪动到老年代的对象大小肯定是非常小的,所以几乎不会在minor gc触发之前由于老年代空间分配担保失败而产生full gc,其实在半小时后发生full gc,这时候已经过了抢购的最高峰期,后续可能几小时才做一次FullGC
对于碎片整理,因为都是1小时或几小时才做一次FullGC,是可以每做完一次就开始碎片整理,或者两到三次之后再做一次也行。
综上,只要年轻代参数设置合理,老年代CMS的参数设置基本都可以用默认值,
垃圾收集器
分代收集理论
当前虚拟机的垃圾收集都采用分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为几块。一般将java堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法
比如在新生代中,每次收集都会有大量对象(近99%)死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。注意,“标记-清除”或“标记-整理”算法会比复制算法慢10倍以上
垃圾收集算法
标记-复制算法
为了解决效率问题,“复制”收集算法出现了。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收
因为是直接复制到另外一块内存,所以会顺序写入,收集之后也是没有内存碎片的
标记-清除算法
算法分为“标记”和“清除”阶段:标记存活的对象, 统一回收所有未被标记的对象(一般选择这种);也可以反过来,标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象 。它是最基础的收集算法,比较简单,但是会带来两个明显的问题:
- 效率问题 (如果需要标记的对象太多,效率不高)
- 空间问题(标记清除后会产生大量不连续的碎片)
标记-整理算法
根据老年代的特点特出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉边界端以外的内存。
比较
就回收效率而言,标记清除/整理比标记复制是要快的,复制多了一个复制的过程
标记清除:标记存活对象,清理未标记对象
标记整理:标记存活对象,移动标记对象
标记复制:标记存活对象,复制对象到s区,清理原空间
标记清除:标记存活对象,清理未标记对象
标记整理:标记存活对象,移动标记对象
标记复制:标记存活对象,复制对象到s区,清理原空间
我们常说的复制比清理/整理快是因为它并不会产生内存碎片,对后续的内存分配友好,而且其内存都是连续的,内存有局部性原则(高速缓存)所以会更快,在回收的时候因为没有碎片扫描也快些,以及年轻代比老年代空间小,小就说明快
垃圾收集器
Serial收集器 [ /ˈsɪriəl/ 串行] (-XX:+UseSerialGC -XX:+UseSerialOldGC)
看名字就知道这个收集器是一个单线程收集器了。它的 “单线程” 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( "Stop The World" ),直到它收集结束
新生代采用复制算法,老年代采用标记-整理算法
Serial收集器有没有优于其他垃圾收集器的地方呢?
当然有,它简单而高效(与其他收集器的单线程相比)。Serial收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率
Serial Old收集器是Serial收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:
- 一种用途是在JDK1.5以及以前的版本中与Parallel Scavenge收集器搭配使用,
- 另一种用途是作为CMS收集器的后备方案
Parallel Scavenge收集器 /ˈpærəlel/ /ˈskævɪndʒ/
(-XX:+UseParallelGC(年轻代),-XX:+UseParallelOldGC(老年代))
(-XX:+UseParallelGC(年轻代),-XX:+UseParallelOldGC(老年代))
Parallel收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和Serial收集器类似。默认的收集线程数跟cpu核数相同,当然也可以用参数(-XX:ParallelGCThreads)指定收集线程数,但是一般不推荐修改
Parallel Scavenge收集器关注点是吞吐量(高效率的利用CPU)。CMS等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。所谓吞吐量就是CPU中用于运行用户代码的时间与CPU总消耗时间的比值
新生代采用复制算法,老年代采用标记-整理算法
Parallel Old收集器是Parallel Scavenge收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及CPU资源的场合,都可以优先考虑 Parallel Scavenge收集器和Parallel Old收集器(JDK8默认的新生代和老年代收集器)
ParNew收集器(-XX:+UseParNewGC) /pɑ nuː/
ParNew收集器其实跟Parallel收集器很类似,区别主要在于它可以和CMS收集器配合使用
新生代采用复制算法,老年代采用标记-整理算法
它是许多运行在Server模式下的虚拟机的首要选择,除了Serial收集器外,只有它能与CMS收集器配合工作
CMS收集器(-XX:+UseConcMarkSweepGC(old))
Concurrent Mark Sweep (并发 标记 清除)
Concurrent Mark Sweep (并发 标记 清除)
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用,它是HotSpot虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作
从名字中的Mark Sweep这两个词可以看出,CMS收集器是一种 “标记-清除”算法实现的,整个过程分为五个步骤:
1、初始标记: 暂停所有的其他线程(STW),并记录下gc roots直接能引用的对象(也就是只标记roots而不往下走),速度很快
2、并发标记: 并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程, 这个过程耗时较长但是不需要停顿用户线程, 可以与垃圾收集线程一起并发运行。因为用户程序继续运行,可能会有导致已经标记过的对象状态发生改变
3、重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。主要用到三色标记里的增量更新算法(见下面详解)做重新标记
4、并发清理: 开启用户线程,同时GC线程开始对未标记的区域做清扫。这个阶段如果有新增对象会被标记为黑色不做任何处理(见下面三色标记算法详解)
5、并发重置:重置本次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的标记阶段时的开销,一般CMS的GC耗时 80%都在标记阶段
-XX:+CMSParallellnitialMarkEnabled:表示在初始标记的时候多线程执行,缩短STW
-XX:+CMSParallelRemarkEnabled:在重新标记的时候多线程执行,缩短STW;
-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的标记阶段时的开销,一般CMS的GC耗时 80%都在标记阶段
-XX:+CMSParallellnitialMarkEnabled:表示在初始标记的时候多线程执行,缩短STW
-XX:+CMSParallelRemarkEnabled:在重新标记的时候多线程执行,缩短STW;
G1收集器(-XX:+UseG1GC)
G1收集器
G1 (Garbage-First)是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征
G1将Java堆划分为多个大小相等的独立区域(Region /ˈriːdʒən/ ),JVM最多可以有2048个Region。一般Region大小等于堆大小除以2048,比如堆大小为4096M,则Region大小为2M,当然也可以用参数"-XX:G1HeapRegionSize"手动指定Region大小,但是推荐默认的计算方式。G1保留了年轻代和老年代的概念,但不再是物理隔阂了,它们都是(可以不连续)Region的集合
默认年轻代对堆内存的占比是5%,可以通过“-XX:G1NewSizePercent”设置新生代初始占比,在系统运行中,JVM会不停的给年轻代增加更多的Region,但是最多新生代的占比不会超过60%,可以通过“-XX:G1MaxNewSizePercent”调整。年轻代中的Eden和Survivor对应的region也跟之前一样,默认8:1:1
一个Region可能之前是年轻代,如果Region进行了垃圾回收,之后可能又会变成老年代,也就是说Region的区域功能可能会动态变化
大对象处理方式
G1垃圾收集器对于对象什么时候会转移到老年代跟之前讲过的原则一样,唯一不同的是对大对象的处理,G1有专门分配大对象的Region叫Humongous/hjuːˈmʌŋɡəs/区,而不是让大对象直接进入老年代的Region中。在G1中,大对象的判定规则就是一个大对象超过了一个Region大小的50%,比如按照上面算的,每个Region是2M,只要一个大对象超过了1M,就会被放入Humongous中,而且一个大对象如果太大,可能会横跨多个Region来存放。Humongous区专门存放短期巨型对象,不用直接进老年代,可以节约老年代的空间,避免因为老年代空间不够的GC开销
Full GC的时候除了收集年轻代和老年代之外,也会将Humongous区一并回收
GC的运作过程
初始标记(initial mark,STW):暂停所有的其他线程,并记录下gc roots直接能引用的对象,速度很快
并发标记(Concurrent Marking):同CMS的并发标记。并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程, 这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。因为用户程序继续运行,可能会有导致已经标记过的对象状态发生改变
最终标记(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/ˌʃenənˈdoʊə/可以看成是G1的升级版本)
优先列表回收
G1收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的Region(这也就是它的名字Garbage-First的由来),比如一个Region花200ms能回收10M垃圾,另外一个Region花50ms能回收20M垃圾,在回收时间有限情况下,G1当然会优先选择后面这个Region回收。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限时间内可以尽可能高的收集效率。
在评估内存区域的回收价值时,垃圾收集器通常会根据以下因素进行评估:
- 存活对象占比:存活对象占内存区域的比例是评估回收价值的关键因素。内存区域中存活对象占比较低的情况通常表示回收价值较高。
- 最终可回收空间大小:需要评估区域中的存活对象和可回收对象的数量,以确定最终可以回收的空间大小。高回收空间表示回收价值高。
- 垃圾回收成本:垃圾回收过程的成本也是一个考量因素。某些区域可能包含大量垃圾对象,但由于回收成本较高,可能会被延迟回收。
- 内存区域的历史回收情况:了解区域的最近一次回收时间以及回收频率,可以帮助评估回收价值。长时间未回收的区域可能有更高的回收价值。
- 内存区域的使用情况:评估区域的当前使用情况,例如是否有频繁分配和释放等情况,可以帮助确定回收价值。频繁分配和释放的区域可能有更高的回收价值。
G1具备的特点
并行与并发:G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短Stop-The-World停顿时间。部分其他收集器原本需要停顿Java线程来执行GC动作,G1收集器仍然可以通过并发的方式让java程序继续执行。
分代收集:虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但是还是保留了分代的概念
空间整合:与CMS的“标记--清理”算法不同,G1从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的。
可预测的停顿:这是G1相对于CMS的另一个大优势,降低停顿时间是G1 和 CMS 共同的关注点,但G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段(通过参数"-XX:MaxGCPauseMillis"指定)内完成垃圾收集
总结:用户可以指定期望的停顿时间是G1收集器很强大的一个功能, 设置不同的期望停顿时间, 可使得G1在不同应用场景中取得关注吞吐量和关注延迟之间的最佳平衡。 不过, 这里设置的“期望值”必须是符合实际的, 不能异想天开, 它默认的停顿目标为两百毫秒, 一般来说, 如果设置的停顿目标时间太短, 导致每次选出来的回收集只占堆内存很小的一部分, 收集器收集的速度逐渐跟不上分配器分配的速度, 导致垃圾慢慢堆积。 很可能一开始收集器还能从空闲的堆内存中获得一些喘息的时间, 但应用运行时间一长就不行了, 最终占满堆引发Full GC反而降低性能, 所以通常把期望停顿时间设置为一两百毫秒或者两三百毫秒会是比较合理的
G1垃圾收集分类
YoungGC
YoungGC并不是说现有的Eden区放满了就会马上触发,G1会计算下现在Eden区回收大概要多久时间,如果回收时间远远小于参数 -XX:MaxGCPauseMills 设定的值,那么增加年轻代的region,继续给新对象存放,不会马上做Young GC,直到下一次Eden区放满,G1计算回收时间接近参数 -XX:MaxGCPauseMills 设定的值,那么就会触发Young GC
MixedGC /mɪkst/
不是FullGC,老年代的堆占有率达到参数(-XX:InitiatingHeapOccupancyPercent)(默认45%)设定的值则触发,回收所有的Young和部分Old(根据期望的GC停顿时间确定old区垃圾收集的优先顺序)以及大对象区,正常情况G1的垃圾收集是先做MixedGC,主要使用复制算法,需要把各个region中存活的对象拷贝到别的region里去,拷贝过程中如果发现没有足够的空region能够承载拷贝对象就会触发一次Full GC
Full GC /fʊl/
停止系统程序,然后采用单线程进行标记、清理和压缩整理,好空闲出来一批Region来供下一次MixedGC使用,这个过程是非常耗时的。(Shenandoah优化成多线程收集了)
G1垃圾收集器优化建议
假设参数 -XX:MaxGCPauseMills 设置的值很大,导致系统运行很久,年轻代可能都占用了堆内存的60%了,此时才触发年轻代gc。那么存活下来的对象可能就会很多,此时就会导致Survivor区域放不下那么多的对象,就会进入老年代中。或者是你年轻代gc过后,存活下来的对象过多,导致进入Survivor区域后触发了动态年龄判定规则,达到了Survivor区域的50%,也会快速导致一些对象进入老年代中。所以这里核心还是在于调节 -XX:MaxGCPauseMills 这个参数的值,在保证他的年轻代gc别太频繁的同时,还得考虑每次gc过后的存活对象有多少,避免存活对象太多快速进入老年代,频繁触发mixed gc
什么场景适合使用G1
- 50%以上的堆被存活对象占用
- 对象分配和晋升的速度变化非常大
- 垃圾回收时间特别长,超过1秒
- 8GB以上的堆内存(建议值)
- 停顿时间是500ms以内
每秒几十万并发的系统如何优化JVM
Kafka类似的支撑高并发消息系统大家肯定不陌生,对于kafka来说,每秒处理几万甚至几十万消息时很正常的,一般来说部署kafka需要用大内存机器(比如64G),也就是说可以给年轻代分配个三四十G的内存用来支撑高并发处理,这里就涉及到一个问题了,我们以前常说的对于eden区的young gc是很快的,这种情况下它的执行还会很快吗?很显然,不可能,因为内存太大,处理还是要花不少时间的,假设三四十G内存回收可能最快也要几秒钟,按kafka这个并发量放满三四十G的eden区可能也就一两分钟吧,那么意味着整个系统每运行一两分钟就会因为young gc卡顿几秒钟没法处理新消息,显然是不行的。那么对于这种情况如何优化了,我们可以使用G1收集器,设置 -XX:MaxGCPauseMills 为50ms,假设50ms能够回收三到四个G内存,然后50ms的卡顿其实完全能够接受,用户几乎无感知,那么整个系统就可以在卡顿几乎无感知的情况下一边处理业务一边收集垃圾
ZGC收集器(-XX:+UseZGC)
ZGC是一款JDK 11中新加入的具有实验性质的低延迟垃圾收集器,ZGC可以说源自于是Azul System公司开发的C4(Concurrent Continuously Compacting Collector) 收集器
ZGC目标
支持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内存布局
小型Region(Small Region) : 容量固定为2MB, 用于放置小于256KB的小对象
中型Region(Medium Region) : 容量固定为32MB, 用于放置大于等于256KB但小于4MB的对象
大型Region(Large Region) : 容量不固定, 可以动态变化, 但必须为2MB的整数倍, 用于放置4MB或以上的大对象。 每个大型Region中只会存放一个大对象, 这也预示着虽然名字叫作“大型Region”, 但它的实际容量完全有可能小于中型Region, 最小容量可低至4MB。 大型Region在ZGC的实现中是不会被重分配(重分配是ZGC的一种处理动作, 用于复制对象的收集器阶段, 稍后会介绍到)的, 因为复制一个大对象的代价非常高昂
ZGC是能自动感知NUMA架构
- NUMA(不统一的内存访问结构)。UMA表示内存只有一块,所有CPU都去访问这一块内存,那么就会存在竞争问题(争夺内存总线访问权),有竞争就会有锁,有锁效率就会受到影响,而且CPU核心数越多,竞争就越激烈。
- NUMA的话每个CPU对应有一块内存,且这块内存在主板上离这个CPU是最近的,每个CPU优先访问这块内存,那效率自然就提高了
ZGC运作过程
并发标记(Concurrent Mark):与G1一样,并发标记是遍历对象图做可达性分析的阶段,它的初始标记(Mark Start)和最终标记(Mark End)也会出现短暂的停顿,与G1不同的是, ZGC的标记是在指针上而不是在对象上进行的, 标记阶段会更新颜色指针【有的称染色指针】中的Marked 0、 Marked 1标志位。(在指针中用几bit表示颜色)
并发预备重分配(Concurrent Prepare for Relocate):这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)。ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本
并发重分配(Concurrent Relocate):重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障(读屏障(见下面详解))所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上(每次通过计算得到需要回收的重分配集合,集合中的每个Region维护一个转发表用来记录旧对象到新对象的转向关系,当有用户线程更改了集合中的对象引用,在访问这个引用的时候就会转发并修改到指向真正的对象地址),并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”(Self-Healing)能力
- ZGC的颜色指针因为“自愈”(Self-Healing)能力,所以只有第一次访问旧对象会变慢, 一旦重分配集中某个Region的存活对象都复制完毕后,
- 这个Region就可以立即释放用于新对象的分配,但是转发表还得留着不能释放掉, 因为可能还有访问在使用这个转发表
并发重映射(Concurrent Remap):重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,但是ZGC中对象引用存在“自愈”功能,所以这个重映射操作并不是很迫切。ZGC很巧妙地把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正它们都是要遍历所有对象的,这样合并就节省了一次遍历对象图的开销。一旦所有指针都被修正之后, 原来记录新旧对象关系的转发表就可以释放掉了
颜色指针
如图所示,ZGC的核心设计之一。以前的垃圾回收器的GC信息都保存在对象头中,而ZGC的GC信息保存在指针中。染色指针是一种直接将少量额外的信息存储在指针上的技术
每个对象有一个64位指针,这64位被分为:
- 18位:预留给以后使用;
- 1位:Finalizable标识,此位与并发引用处理有关,它表示这个对象只能通过finalizer才能访问;
- 1位:Remapped标识,设置此位的值后,对象未指向relocation set中(relocation set表示需要GC的Region集合);
- 1位:Marked1标识;
- 1位:Marked0标识,和上面的Marked1都是标记对象用于辅助GC;
- 42位:对象的地址(所以它可以支持2^42=4T内存):
为什么有2个mark标记?
- 每一个GC周期开始时,会交换使用的标记位,使上次GC周期中修正的已标记状态失效,所有引用都变成未标记。
- GC周期1:使用mark0, 则周期结束所有引用mark标记都会成为01。
- GC周期2:使用mark1, 则期待的mark标记10,所有引用都能被重新标记。
- 通过对配置ZGC后对象指针分析我们可知,对象指针必须是64位,那么ZGC就无法支持32位操作系统,同样的也就无法支持压缩指针了(CompressedOops,压缩指针也是32位)
颜色指针的三大优势:
- 一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理(因为存在引用中),这使得理论上只要还有一个空闲Region,ZGC就能完成收集。
- 颜色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,ZGC只使用了读屏障。
- 颜色指针具备强大的扩展性,它可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能。
ZGC中的读屏障
简单说就是在读取对象的时候,如果这个指针是Bad Color,则通过读屏障将指针指向新地址(通过转发表得到新地址),指针上面会有4位用来标记颜色,可以通过这个得到这个对象是good Color还是Bad Color
ZGC存在的问题
ZGC最大的问题是浮动垃圾。ZGC的停顿时间是在10ms以下,但是ZGC的执行时间还是远远大于这个时间的。假如ZGC全过程需要执行10分钟,在这个期间由于对象分配速率很高,将创建大量的新对象,这些对象很难进入当次GC,所以只能在下次GC的时候进行回收,这些只能等到下次GC才能回收的对象就是浮动垃圾。
ZGC没有分代概念,每次都需要进行全堆扫描,导致一些“朝生夕死”的对象没能及时的被回收
ZGC没有分代概念,每次都需要进行全堆扫描,导致一些“朝生夕死”的对象没能及时的被回收
解决方案
目前唯一的办法是增大堆的容量,使得程序得到更多的喘息时间,但是这个也是一个治标不治本的方案。如果需要从根本上解决这个问题,还是需要引入分代收集,让新生对象都在一个专门的区域中创建,然后专门针对这个区域进行更频繁、更快的收集
目前唯一的办法是增大堆的容量,使得程序得到更多的喘息时间,但是这个也是一个治标不治本的方案。如果需要从根本上解决这个问题,还是需要引入分代收集,让新生对象都在一个专门的区域中创建,然后专门针对这个区域进行更频繁、更快的收集
ZGC触发GC时机
定时触发,默认为不使用,可通过ZCollectionInterval参数配置
预热触发,最多三次,在堆内存达到10%、20%、30%时触发,主要时统计GC时间,为其他GC机制使用
分配速率,基于正态分布统计,计算内存99.9%可能的最大分配速率,以及此速率下内存将要耗尽的时间点,在耗尽之前触发GC(耗尽时间 - 一次GC最大持续时间 - 一次GC检测周期时间)。
主动触发,(默认开启,可通过ZProactive参数配置) 距上次GC堆内存增长10%,或超过5分钟时,对比距上次GC的间隔时间跟(49 * 一次GC的最大持续时间),超过则触发
垃圾收集底层算法实现
三色标记
白色: 表示对象尚未被垃圾收集器访问过。 显然在可达性分析刚刚开始的阶段, 所有的对象都是白色的, 若在分析结束的阶段, 仍然是白色的对象, 即代表不可达。白色表示没扫描到的,但不代表不会扫描到。只有说结束后还是白色才为是垃圾对象
灰色: 表示对象已经被垃圾收集器访问过, 但这个对象上至少存在一个引用还没有被扫描过。
黑色: 表示对象已经被垃圾收集器访问过, 且这个对象的所有引用都已经扫描过。 黑色的对象代表已经扫描过, 它是安全存活的, 如果有其他对象引用指向了黑色对象, 无须重新扫描一遍。 黑色对象不可能直接(不经过灰色对象) 指向某个白色对象
多标-浮动垃圾
- 无需解决
在并发标记过程中,如果由于方法运行结束导致部分局部变量(gcroot)被销毁,这个gcroot引用的对象之前又被扫描过(被标记为非垃圾对象),那么本轮GC不会回收这部分内存。这部分本应该回收但是没有回收到的内存,被称之为“浮动垃圾”。浮动垃圾并不会影响垃圾回收的正确性,只是需要等到下一轮垃圾回收中才被清除
另外,针对并发标记(还有并发清理)开始后产生的新对象,通常的做法是直接全部当成黑色,本轮不会进行清除。这部分对象期间可能也会变为垃圾,这也算是浮动垃圾的一部分
漏标解决方案
- 必须解决
增量更新(Incremental Update)
增量更新就是当黑色对象插入新的指向白色对象的引用关系时, 就将这个新插入的引用记录下来, 等并发扫描结束之后, 再将这些记录过的引用关系中的黑色对象为根, 重新扫描一次。 这可以简化理解为, 黑色对象一旦新插入了指向白色对象的引用之后, 它就变回灰色对象了。
写屏障实现增量更新:当对象A的成员变量的引用发生变化时,比如新增引用(a.d = d),我们可以利用写屏障,将A新的成员变量引用对象D记录下来:
会重新扫描,慢但浮动垃圾少
黑色新增指向白色对象,因为黑色对象不会再扫描,所以这个白色对象不知道是否为垃圾对象,需要重新扫描
原始快照(Snapshot At The Beginning,SATB)
原始快照就是当灰色对象要删除指向白色对象的引用关系时, 就将这个要删除的引用记录下来, 在并发扫描结束之后, 再将这些记录过的引用关系中的灰色对象为根, 重新扫描一次,这样就能扫描到白色的对象(注意这里不是从新的对象图中找,而是从保存的引用关系中找到白色对象,再将白色标记为黑色),将白色对象直接标记为黑色(目的就是让这种对象在本轮gc清理中能存活下来,待下一轮gc的时候重新扫描,这个对象也有可能是浮动垃圾)
写屏障实现SATB:当对象B的成员变量的引用发生变化时,比如引用消失(a.b.d = null),我们可以利用写屏障,将B原来成员变量的引用对象D记录下来
直接标记成黑色,快但是浮动垃圾多
灰色删除白色对象,本来这个白色对象是会扫描的,但是删除后没法扫描到,不知道是否为垃圾对象,就需要处理
这里其实有个疑问,就是直接标记为黑色的话,那白色对象下面的对象呢?要不就得扫描,要不就得全部标记为黑色
这里其实有个疑问,就是直接标记为黑色的话,那白色对象下面的对象呢?要不就得扫描,要不就得全部标记为黑色
各垃圾收集器解决方案
CMS:写屏障 >实现> 增量更新
G1,Shenandoah:写屏障 >实现> SATB
ZGC:读屏障
读写屏障
写屏障:其实就是指在赋值操作前后,加入一些处理(可以参考AOP的概念)
读屏障:其实就是在读取操作前将数据保存起来
为什么G1用SATB?CMS用增量更新?
我的理解:SATB相对增量更新效率会高(当然SATB可能造成更多的浮动垃圾),因为不需要在重新标记阶段再次深度扫描被删除引用对象,而CMS对增量引用的根对象会做深度扫描,G1因为很多对象都位于不同的region,CMS就一块老年代区域,重新深度扫描对象的话G1的代价会比CMS高,所以G1选择SATB不深度扫描对象,只是简单标记,等到下一轮GC再深度扫描
记忆集与卡表
在minor gc的时候,可能存在一些对象是被老年代的对象的变量引用着,如果说在gcroot可达性分析的时候不去扫描老年代中的对象,可能新生代中会存在一些非垃圾对象无法被标记到,这就存在问题,但是如果直接去扫描全部的老年代对象代价太高了。所以虚拟机就使用记忆集卡表来维护:只要老年代中对象的变量引用了新生代的对象(写屏障实现),就将这个老年代对象中的卡页标记成1,代表元素脏,在minor gc的时候会扫描所有变脏的卡页,而不用把全部的老年代对象全部扫描一遍,也就能解决上面的效率问题了。在实际中,其实跨代引用的对象极少的
卡表是使用一个字节数组实现:CARD_TABLE[ ],每个元素对应着其标识的内存区域一块特定大小的内存块,称为“卡页”
一个卡页中可包含多个对象,只要有一个对象的字段存在跨代指针,其对应的卡表的元素标识就变成1,表示该元素变脏,否则为0.
卡表为一个位数组,0101这种,初始全是0,,变脏设置1,每一个0或者1对应一块卡页,卡表存在新生代,卡页存在老年代
卡表为一个位数组,0101这种,初始全是0,,变脏设置1,每一个0或者1对应一块卡页,卡表存在新生代,卡页存在老年代
如何选择垃圾收集器
- 优先调整堆的大小让服务器自己来选择
- 如果内存小于100M,使用串行收集器
- 如果是单核,并且没有停顿时间的要求,串行或JVM自己选择
- 如果允许停顿时间超过1秒,选择并行或者JVM自己选
- 如果响应时间最重要,并且不能超过1秒,使用并发收集器
- 4G以下可以用parallel,4-8G可以用ParNew+CMS,8G以上可以用G1,几百G以上用ZGC
JDK 1.8默认使用 Parallel(年轻代和老年代都是)
JDK 1.9默认使用 G1
安全点与安全区域
安全点
安全点的理解:在GC要开始的时候,会设置一个GC的开始标记,用户线程中在到达一个安全点的时候都会去断言检查这个标记,如果是GC开始的标记就停止自己线程,当所有用户线程都到达安全点并停止后,GC才能开始。如果没有这个机制,那么就会造成我们代码的一些执行不对,比如我们一行代码可能对应多个jvm指令,那总得等到这些指令都执行完才能GC吧,不然会打乱代码的原子性,又比如我们多行代码有牵扯原子性不能暂停后再继续运行,这也是一样的道理。这些点就是安全点
所以GC不是想什么时候做就立即触发的,是需要等待所有线程运行到安全点后才能触发
所以GC不是想什么时候做就立即触发的,是需要等待所有线程运行到安全点后才能触发
安全区域
安全区域的理解:比如一个线程执行到了sleep操作了,并且sleep很久,难道我们GC就要等到这个sleep结束才开始GC吗。也就是说在这一行或者这一段代码的运行时不会改变我们GC所关心的对象引用的变化时,它是一直安全的,可以随时暂停该用户线程去开始GC,这些区域就叫做安全区域
JVM调优工具--基础故障处理工具
基础故障处理工具
jps:虚拟机进程状况工具
可以列出正在运行的虚拟机进程,并显示虚拟机执行主类(Main Class,main()函数所在的类)名称以及这些进程的本地虚拟机唯一ID(LVMID,Local Virtual Machine Identifier)
jps命令格式:jps [ options ] [ hostid ]
- -q: 只输出 LVMID,省略主类的名称
- -m: 输出虚拟机进行启动时传递给主类 main() 函数的参数
- -l: 输出主类的全名,如果进程执行的是 JAR 包,则输出 JAR 路径
- -v: 输出虚拟机进程启动时的 JVM 参数
jinfo:Java配置信息工具
jinfo(Configuration Info for Java)的作用是实时查看和调整虚拟机各项参数。使用jps命令的-v参数可以查看虚拟机启动时显式指定的参数列表,但如果想知道未被显式指定的参数的系统默认值,除了去找资料外,就只能使用jinfo的-flag选项进行查询了
jinfo命令格式:jinfo [ option ] pid
- -flags 查看jvm的参数
- -sysprops 查看java系统参数
- -flag [+|-] 修改正在运行的Java应用程序JVM参数,其中+是开启对应参数,-是关闭对应参数
jmap:Java内存映像工具
jmap(Memory Map for Java)命令用于生成堆转储快照(一般称为heapdump或dump文件)。
jmap命令格式:jmap [ option ] vmid
- -dump: 使用hprof二进制形式输出jvm的heap内容到文件,生成 Java 堆转储快照。格式为 -dum:[live,]format=b,file=filename, 其中 live 子参数说明是否只 dump 存活的对象。
- -finalizeinfo: 显示在F-Queue 中等待Finalizer 线程执行 finalize 方法的对象。只在 Linux/Solaris 平台有效。
- -heap: 显示 Java 堆详细信息,如使用哪种回收器、参数配置、分代状况等。只在 Linux/Solaris 平台有效。
- -histo: 显示堆中对象统计信息,包括类、实例数量、合计容量。
- -permstat: 以 ClassLoader 为统计口径显示永久代内存状态。只在 Linux/Solaris 平台有效。
- -F: 当虚拟机进程对 -dump 选项没有响应时,可使用这个选项强制生成 dump 快照。只在 Linux/Solaris 平台有效。
jmap执行期间会对进程产生很大影响,甚至卡顿,线上慎用
其它方式获得堆转储快照文件
-XX:+HeapDumpOnOutOfMemoryError:当OutOfMemoryError发生时自动生成 Heap Dump 文件。
-XX:+HeapDumpBeforeFullGC:当 JVM 执行 FullGC 前执行 dump。
-XX:+HeapDumpAfterFullGC:当 JVM 执行 FullGC 后执行 dump。
-XX:+HeapDumpOnCtrlBreak:交互式获取dump。在控制台按下快捷键Ctrl + Break时,JVM就会转存一下堆快照。
-XX:HeapDumpPath=/Users/apple/Downloads/test.hprof:指定 dump 文件存储路径。
-XX:+HeapDumpBeforeFullGC:当 JVM 执行 FullGC 前执行 dump。
-XX:+HeapDumpAfterFullGC:当 JVM 执行 FullGC 后执行 dump。
-XX:+HeapDumpOnCtrlBreak:交互式获取dump。在控制台按下快捷键Ctrl + Break时,JVM就会转存一下堆快照。
-XX:HeapDumpPath=/Users/apple/Downloads/test.hprof:指定 dump 文件存储路径。
jstat /stæt/:虚拟机统计信息监视工具
jstat(JVM Statistics Monitoring Tool)是用于监视虚拟机各种运行状态信息的命令行工具。它可以显示本地或者远程 [1] 虚拟机进程中的类加载、内存、垃圾收集、即时编译等运行时数据,在没有GUI图形界面、只提供了纯文本控制台环境的服务器上,它将是运行期定位虚拟机性能问题的常用工具
jstat命令格式:jstat [ option vmid [interval[s|ms] [count]] ]
- -class: 监视类加载,卸载数量、总空间以及类装载所耗费的时间
- -gc: 监视 Java 堆状况,包括 Eden 区、2个 Survivor 区、老年区、永久代等的容量,已用空间,垃圾收集时间合计等信息
- -gccapacity: 监视内容与 -gc 基本相同,但输出主要关注 Java 堆各个区域使用到的最大、最小空间
- -gcutil: 监视内容与 -gc 基本相同,但输出主要关注已使用空间占总空间的百分比
- -gccause: 与 -gcutil功能一样,但是会额外输出导致上一次垃圾收集产生的原因
- -gcnew: 监视新生代垃圾收集状况
- -gcnewcapacity: 监视内容与 -gcnew 基本相同,输出主要关注使用到的最大、最小空间
- -gcold: 监视老年代垃圾收集状况
- -gcoldcapacity: 监视内容与 -gcold 基本相同,输出主要关注使用到的最大、最小空间
- -gcpermcapacity: 输出永久代使用的最大、最小空间
- -compiler: 输出即时编译器编译过的方法、耗时等信息
- -printcompilation: 输出已经被即时编译的方法
假设需要每250毫秒查询一次进程2764垃圾收集状况,一共查询20次,那命令应当是:jstat -gc 2764 250 20
jstack/stæk/:Java堆栈跟踪工具
jstack(Stack Trace for Java)命令用于生成虚拟机当前时刻的线程快照。线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的目的通常是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间挂起等,都是导致线程长时间停顿的常见原因。线程出现停顿时通过jstack来查看各个线程的调用堆栈,就可以获知没有响应的线程到底在后台做些什么事情,或者等待着什么资源
jstack命令格式:jstack [ option ] vmid
- -F: 当正常输出的请求不被响应时,强制输出线程堆栈。
- -l: 除堆栈外,显示关于锁的附加信息
- -m: 如果遇到本地方法的话,可以显示 C/C++ 的堆栈
JVM运行情况预估
年轻代对象增长的速率
可以执行命令 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性能的影响
Class常量池与运行时常量池以及字符串常量池
class常量池
- 字面量
- 符号引用
Class常量池可以理解为是Class文件中的资源仓库。 Class文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(constant pool table),用于存放编译期生成的各种字面量(Literal)和符号引用(Symbolic References)【就是class二进制文件的内容有部分是常量池】
字面量:字面量就是指由字母、数字等构成的字符串或者数值常量。如:int a=1;1就是字面量
符号引用:符号引用是编译原理中的概念,是相对于直接引用来说的。主要包括了以下三类常量:
比如:int a=1;a就是字段的符号引用,=就是字段的描述符,他们都是符号引用
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
比如:int a=1;a就是字段的符号引用,=就是字段的描述符,他们都是符号引用
字符串常量池
字符串常量池的设计思想
- 字符串的分配,和其他的对象分配一样,耗费高昂的时间与空间代价,作为最基础的数据类型,大量频繁的创建字符串,极大程度地影响程序的性能
- JVM为了提高性能和减少内存开销,在实例化字符串常量的时候进行了一些优化:
- 为字符串开辟一个字符串常量池,类似于缓存区
- 创建字符串常量时,首先查询字符串常量池是否存在该字符串
- 存在该字符串,返回引用实例,不存在,实例化该字符串并放入池中
三种字符串操作
直接赋值字符串:String s = "libo"; // s指向常量池中的引用
new String():String s1 = new String("libo"); // s1指向内存中的对象引用(堆中的地址)
- 这种方式会保证字符串常量池和堆中都有这个对象,没有就创建,最后返回堆内存中的对象引用
intern方法:String s2 = s1.intern();如果说常量池中存在则指向常量池地址,如果不存在,则指向s1对象堆中的地址
- 在1.6的时候还会将字符串复制到常量池中去,并指向的是常量池中的地址
字符串常量池位置
- Jdk1.6及之前: 有永久代, 运行时常量池在永久代,运行时常量池包含字符串常量池
- Jdk1.7:有永久代,但已经逐步“去永久代”,字符串常量池从永久代里的运行时常量池分离到堆里
- Jdk1.8及之后: 无永久代,运行时常量池在元空间,字符串常量池里依然在堆里
案例分析
String s1 = new String("he") + new String("llo");
相当于new StringBuilder("he").append("llo").toString();而toString里面为new String(value, 0, count)所以s1>>堆
String s2 = s1.intern();
System.out.println(s1 == s2);
// 在 JDK 1.6 下输出是 false,创建了 6 个对象
// 在 JDK 1.7 及以上的版本输出是 true,创建了 5 个对象
// 当然我们这里没有考虑GC,但这些对象确实存在或存在过
相当于new StringBuilder("he").append("llo").toString();而toString里面为new String(value, 0, count)所以s1>>堆
String s2 = s1.intern();
System.out.println(s1 == s2);
// 在 JDK 1.6 下输出是 false,创建了 6 个对象
// 在 JDK 1.7 及以上的版本输出是 true,创建了 5 个对象
// 当然我们这里没有考虑GC,但这些对象确实存在或存在过
为什么输出会有这些变化呢?主要还是字符串池从永久代中脱离、移入堆区的原因, intern() 方法也相应发生了变化
- 1、在 JDK 1.6 中,调用 intern() 首先会在字符串池中寻找 equal() 相等的字符串,假如字符串存在就返回该字符串在字符串池中的引用;假如字符串不存在,虚拟机会重新在永久代上创建一个实例,将 StringTable 的一个表项指向这个新创建的实例。s1指向的是堆中的地址,当执行完第一行代码的时候,堆中存在对象“hello”,常量池中不存在“hello”,然后intern()会在常量池创建“hello”
- 2、在 JDK 1.7 (及以上版本)中,由于字符串池不在永久代了,intern() 做了一些修改,更方便地利用堆中的对象。字符串存在时和 JDK 1.6一样,但是字符串不存在时不再需要重新创建实例,可以直接指向堆上的实例
String s2="li" + "bo";//编译器直接优化成“libo”
String s2="li" + new String("bo");
用new String() 创建的字符串不是常量,不能在编译期就确定,所以编译器不能直接优化
用new String() 创建的字符串不是常量,不能在编译期就确定,所以编译器不能直接优化
问题整理
说一下 JVM 的主要组成部分及其作用?
JVM包含两个子系统和两个组件,两个子系统为Class loader(类加载子系统)、Execution engine(执行引擎);两个组件为Runtime data area(运行时数据区)、Native Interface(本地接口)
作用 :首先通过编译器(javac命令)把 Java 代码转换成字节码,类加载器(ClassLoader)再把字节码加载到内存中,将其放在运行时数据区(Runtime data area)的方法区内,而字节码文件只是 JVM 的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解析器执行引擎(Execution Engine),将字节码翻译成底层系统指令,再交由 CPU 去执行,而这个过程中需要调用其他语言的本地库接口(Native Interface)来实现整个程序的功能
- Class loader(类加载子系统):根据给定的全限定名类名(如:java.lang.Object)来装载class文件到Runtime data area中的method area。
- Execution engine(执行引擎):执行classes中的指令。
- Native Interface(本地接口):与native libraries交互,是其它编程语言交互的接口。
- Runtime data area(运行时数据区域):这就是我们常说的JVM的内存。
作用 :首先通过编译器(javac命令)把 Java 代码转换成字节码,类加载器(ClassLoader)再把字节码加载到内存中,将其放在运行时数据区(Runtime data area)的方法区内,而字节码文件只是 JVM 的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解析器执行引擎(Execution Engine),将字节码翻译成底层系统指令,再交由 CPU 去执行,而这个过程中需要调用其他语言的本地库接口(Native Interface)来实现整个程序的功能
说一下 JVM 运行时数据区
程序计数器:当前线程所执行的字节码的行号指示器。字节码解析器的工作是通过改变这个计数器的值,来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能,都需要依赖这个计数器来完成
Java虚拟机栈:虚拟机栈描述的是Java方法执行的线程内存模型,每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程
本地方法栈:与虚拟机栈的作用是一样的,只不过虚拟机栈是服务 Java 方法的,而本地方法栈是为 Native 方法服务的
Java 堆:Java 虚拟机中内存最大的一块,是被所有线程共享的,几乎所有的对象、数组都在这里分配内存;由于即时编译技术的进步,尤其是逃逸分析技术的日渐强大,部分对象会直接在栈上分配
方法区(元空间):用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据。JDK 7的HotSpot,已经把原本放在永久代的字符串常量池、静态变量等移出,而到了JDK 8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Meta-space)来代替,把JDK 7中永久代还剩余的内容(主要是类型信息)全部移到元空间中
- Jdk1.6及之前: 有永久代, 运行时常量池在永久代,运行时常量池包含字符串常量池
- Jdk1.7:有永久代,但已经逐步“去永久代”,字符串常量池从永久代里的运行时常量池分离到堆里
- Jdk1.8及之后: 无永久代,运行时常量池在元空间,字符串常量池里依然在堆里
深拷贝和浅拷贝
浅拷贝(shallowCopy)只是增加了一个指针指向已存在的内存地址,
深拷贝(deepCopy)是增加了一个指针并且申请了一个新的内存,使这个增加的指针指向这个新的内存,
浅拷贝指向同一个地址,深拷贝相当于新创建了个对象,指向不同地址
浅复制:仅仅是指向被复制的内存地址,如果原地址发生改变,那么浅复制出来的对象也会相应的改变。
深复制:在计算机中开辟一块新的内存地址用于存放复制的对象
深拷贝(deepCopy)是增加了一个指针并且申请了一个新的内存,使这个增加的指针指向这个新的内存,
浅拷贝指向同一个地址,深拷贝相当于新创建了个对象,指向不同地址
浅复制:仅仅是指向被复制的内存地址,如果原地址发生改变,那么浅复制出来的对象也会相应的改变。
深复制:在计算机中开辟一块新的内存地址用于存放复制的对象
说一下堆栈的区别?
1、申请方式的不同。栈由系统自动分配,分配效率高,而堆是人为申请开辟,分配效率低;
2、申请大小的不同。栈获得的空间较小,而堆获得的空间较大;
3、栈是连续的空间,而堆是不连续的空间。
4、堆存放的是对象的实例和数组,栈存放的是局部变量表、操作数栈、动态连接、方法出口
5、栈是线程私有的,堆是线程共享的
对象的创建方式有哪些?
1、使用new关键字
2、使用Class的newInstance /ˈɪnstəns/方法
3、使用Constructor /kənˈstrʌktər/ 类的newInstance /ˈɪnstəns/方法
4、使用clone方法
5、使用反序列化
对象创建的主要流程
类加载检查
虚拟机遇到一条new指令时,首先将去检查这个指令的参数(无参构造的话只有一个new指令,参数为自己类,有参的话有多少个就有多少个new、dup、invokespecial指令)是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程
分配内存
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来
初始化
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头), 如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值
设置对象头
初始化零值之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头Object Header之中。
执行<init>方法
执行<init>方法,即对象按照程序员的意愿进行初始化。对应到语言层面上讲,就是为属性赋值(注意,这与上面的赋零值不同,这是由程序员赋的值),和执行构造方法。这个init方法不要想着就是构造方法,要是构造方法为空构造呢?所以不要这么想
对象划分内存的方法
指针碰撞(默认)
如果Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离
空闲列表
如果Java堆中的内存并不是规整的,已使用的内存和空 闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记 录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例, 并更新列表上的记录
内存分配并发问题
问题原因:在并发情况下,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况
解决方法:
- CAS:虚拟机采用CAS配上失败重试的方式保证更新操作的原子性来对分配内存空间的动作进行同步处理
- 本地线程分配缓冲:把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存。如果分配的内存不够了就接着使用上面那种
对象的访问定位
句柄访问:Java堆中划分出一块内存来作为句柄池,引用中存储对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息
直接指针(HotSpot 中采用的就是这种方式):如果使用直接指针访问,引用中存储的直接就是对象地址,那么Java堆对象内部的布局中就必须考虑如何放置访问类型数据的相关信息(klass指针)
对象在内存中存储的布局
对象头
- 第一部分用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。
- 另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例
- 如果是数组,还有一部分为数组长度
实例数据
对象真正存储的有效信息。无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来
对齐填充
并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍(站在计算机组成原理的角度上讲,64位操作系统采用8字节的寻址是最快的,所以在不满足的时候就对齐8的整数倍去填充)。换句话说就是任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好是8字节的倍数(1倍或者2倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全
Java会存在内存泄漏吗?请简单描述
内存泄漏是指不再被使用的对象或者变量一直被占据在内存中。理论上来说,Java是有GC垃圾回收机制的,也就是说,不再被使用的对象,会被GC自动回收掉,自动从内存中清除
但是,即使这样,Java也还是存在着内存泄漏的情况,java导致内存泄露的原因很明确:长生命周期的对象持有短生命周期对象的引用就很可能发生内存泄露,尽管短生命周期对象已经不再需要,但是因为长生命周期对象持有它的引用而导致不能被回收,这就是java中内存泄露的发生场景
简述Java垃圾回收机制
在java中,程序员是不需要显示的去释放一个对象的内存的,而是由虚拟机自行执行。在JVM中,有一个垃圾回收线程,它是低优先级的,在正常情况下是不会执行的,只有在虚拟机空闲或者当前堆内存不足时,才会触发执行,扫描那些没有被任何引用的对象,并将它们添加到要回收的集合中,进行回收
GC是什么?为什么要GC
GC 是垃圾收集的意思(Gabage Collection),内存处理是编程人员容易出现问题的地方,忘记或者错误的内存回收会导致程序或系统的不稳定甚至崩溃,Java 提供的 GC 功能可以自动监测对象是否超过作用域从而达到自动回收内存的目的,Java 语言没有提供释放已分配内存的显示操作方法。
- 垃圾回收器的基本原理是什么?
- 垃圾回收器可以马上回收内存吗?
- 有什么办法主动通知虚拟机进行垃圾回收?
对于GC来说,当程序员创建对象时,GC就开始监控这个对象的地址、大小以及使用情况。
通常,GC采用有向图的方式记录和管理堆(heap)中的所有对象。通过这种方式确定哪些对象是"可达的",哪些对象是"不可达的"。当GC确定一些对象为"不可达"时,GC就有责任回收这些内存空间。
可以。程序员可以手动执行System.gc() /ˈsɪstəm/,通知GC运行,但是并不保证GC一定会执行
通常,GC采用有向图的方式记录和管理堆(heap)中的所有对象。通过这种方式确定哪些对象是"可达的",哪些对象是"不可达的"。当GC确定一些对象为"不可达"时,GC就有责任回收这些内存空间。
可以。程序员可以手动执行System.gc() /ˈsɪstəm/,通知GC运行,但是并不保证GC一定会执行
Java 中都有哪些引用类型?
强引用:普通的变量引用
软引用:将对象用SoftReference软引用类型的对象包裹,正常情况不会被回收,但是GC做完后发现释放不出空间存放新的对象,则会把这些软引用的对象回收掉。软引用可用来实现内存敏感的高速缓存
弱引用:将对象用WeakReference软引用类型的对象包裹,弱引用跟没引用差不多,GC会直接回收掉,很少用
虚引用:虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系,几乎不用
怎么判断对象是否可以被回收?
引用计数法:给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加1;当引用失效,计数器就减1;任何时候计数器为0的对象就是不可能再被使用的。这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题
可达性分析算法:将“GC Roots” 对象作为起点,从这些节点开始向下搜索引用的对象,找到的对象都标记为非垃圾对象,其余未标记的对象都是垃圾对象。
- GC Roots根节点:线程栈的本地变量、静态变量、本地方法栈的变量等等
在Java中,对象什么时候可以被垃圾回收
当对象对当前使用这个对象的应用程序变得不可触及的时候,这个对象就可以被回收了。
垃圾回收不会发生在永久代,如果永久代满了或者是超过了临界值,会触发完全垃圾回收(Full GC)。如果你仔细查看垃圾收集器的输出信息,就会发现永久代也是被回收的。这就是为什么正确的永久代大小对避免Full GC是非常重要的原因
垃圾回收不会发生在永久代,如果永久代满了或者是超过了临界值,会触发完全垃圾回收(Full GC)。如果你仔细查看垃圾收集器的输出信息,就会发现永久代也是被回收的。这就是为什么正确的永久代大小对避免Full GC是非常重要的原因
JVM中的永久代中会发生垃圾回收吗
垃圾回收不会发生在永久代,如果永久代满了或者是超过了临界值,会触发完全垃圾回收(Full GC)。如果你仔细查看垃圾收集器的输出信息,就会发现永久代也是被回收的。这就是为什么正确的永久代大小对避免Full GC是非常重要的原因。请参考下Java8:从永久代到元数据区
(译者注:Java8中已经移除了永久代,新加了一个叫做元数据区的native内存区)
(译者注:Java8中已经移除了永久代,新加了一个叫做元数据区的native内存区)
说一下 JVM 有哪些垃圾回收算法?
复制算法:按照容量划分二个大小相等的内存区域,当一块用完的时候将活着的对象复制到另一块上,然后再把已使用的内存空间一次清理掉。缺点:内存使用率不高,只有原来的一半
标记-清除算法:标记存活的对象, 统一回收所有未被标记的对象,但是会带来两个明显的问题:
- 效率问题 (如果需要标记的对象太多,效率不高)
- 空间问题(标记清除后会产生大量不连续的碎片)
标记-整理算法:根据老年代的特点特出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉边界端以外的内存。
说一下 JVM 有哪些垃圾回收器?
如果说垃圾收集算法是内存回收的方法论,那垃圾收集器就是内存回收的具体实现。其中用于回收新生代的收集器包括Serial、PraNew、Parallel Scavenge,回收老年代的收集器包括Serial Old、Parallel Old、CMS,还有用于回收整个Java堆的G1收集器。不同收集器之间的连线表示它们可以搭配使用
新生代垃圾回收器和老年代垃圾回收器都有哪些?有什么区别?
新生代垃圾回收器一般采用的是复制算法,复制算法的优点是效率高,缺点是内存利用率低;老年代回收器一般采用的是标记-整理的算法进行垃圾回收
- 新生代回收器:Serial、ParNew、Parallel Scavenge
- 老年代回收器:Serial Old、Parallel Old、CMS
- 整堆回收器:G1
新生代垃圾回收器一般采用的是复制算法,复制算法的优点是效率高,缺点是内存利用率低;老年代回收器一般采用的是标记-整理的算法进行垃圾回收
简述分代垃圾回收器是怎么工作的?
分代回收器有两个分区:老生代和新生代,新生代默认的空间占比总空间的 1/3,老生代的默认占比是 2/3。
新生代使用的是复制算法,新生代里有 3 个分区:Eden、To Survivor、From Survivor,它们的默认占比是 8:1:1,它的执行流程如下:
老生代当空间占用到达某个值之后就会触发全局垃圾收回(full gc),一般使用标记整理的执行算法。以上这些循环往复就构成了整个分代垃圾回收的整体执行流程。
新生代使用的是复制算法,新生代里有 3 个分区:Eden、To Survivor、From Survivor,它们的默认占比是 8:1:1,它的执行流程如下:
- 把 Eden + From Survivor 存活的对象放入 To Survivor 区;
- 清空 Eden 和 From Survivor 分区;
- From Survivor 和 To Survivor 分区交换,From Survivor 变 To Survivor,To Survivor 变 From Survivor。
老生代当空间占用到达某个值之后就会触发全局垃圾收回(full gc),一般使用标记整理的执行算法。以上这些循环往复就构成了整个分代垃圾回收的整体执行流程。
简述java内存分配
- 具体看上面知识点
对象栈上分配
对象优先在 Eden 区分配
大对象直接进入老年代
长期存活对象将进入老年代
对象动态年龄判断
老年代空间分配担保机制
简述java类加载机制?
虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验,解析和初始化,最终形成可以被虚拟机直接使用的java类型
什么是类加载器,类加载器有哪些?
- 引导类加载器:负责加载支撑JVM运行的位于JRE的lib目录下的核心类库,比如rt.jar、charsets.jar等
- 扩展类加载器:负责加载支撑JVM运行的位于JRE的lib目录下的ext扩展目录中的JAR类包
- 应用程序类加载器:负责加载ClassPath路径下的类包,主要就是加载你自己写的那些类
- 自定义加载器:负责加载用户自定义路径下的类包
说一下类装载的执行过程?
加载:根据查找路径找到相应的 class 文件然后导入;
验证:检查加载的 class 文件的正确性;
准备:给类中的静态变量分配内存空间,赋零值;
解析:虚拟机将常量池中的符号引用替换成直接引用的过程。符号引用就理解为一个标示,而在直接引用直接指向内存中的地址;
初始化:对静态变量和静态代码块执行初始化工作
验证:检查加载的 class 文件的正确性;
准备:给类中的静态变量分配内存空间,赋零值;
解析:虚拟机将常量池中的符号引用替换成直接引用的过程。符号引用就理解为一个标示,而在直接引用直接指向内存中的地址;
初始化:对静态变量和静态代码块执行初始化工作
什么是双亲委派模型?
如果一个类加载器收到了类加载的请求,它首先不会自己去加载这个类,而是把这个请求委派给父类加载器去完成,每一层的类加载器都是如此,这样所有的加载请求都会被传送到顶层的启动类加载器中,只有当父加载无法完成加载请求(它的搜索范围中没找到所需的类)时,子加载器才会尝试去加载类。
说一下 JVM 调优的工具?
JDK 自带了很多监控工具,都位于 JDK 的 bin 目录下,其中最常用的是 jconsole 和 jvisualvm 这两款视图监控工具。
- jconsole:用于对 JVM 中的内存、线程和类等进行监控;
- jvisualvm:JDK 自带的全能分析工具,可以分析:内存快照、线程快照、程序死锁、监控内存的变化、gc 变化等
常用的 JVM 调优的参数都有哪些?
-Xms2g:初始化推大小为 2g;
-Xmx2g:堆最大内存为 2g;
-XX:NewRatio=4:设置年轻的和老年代的内存比例为 1:4;
-XX:SurvivorRatio=8:设置新生代 Eden 和 Survivor 比例为 8:2;
–XX:+UseParNewGC:指定使用 ParNew + Serial Old 垃圾回收器组合;
-XX:+UseParallelOldGC:指定使用 ParNew + ParNew Old 垃圾回收器组合;
-XX:+UseConcMarkSweepGC:指定使用 CMS + Serial Old 垃圾回收器组合;
-XX:+PrintGC:开启打印 gc 信息;
-XX:+PrintGCDetails:打印 gc 详细信息。
-Xmx2g:堆最大内存为 2g;
-XX:NewRatio=4:设置年轻的和老年代的内存比例为 1:4;
-XX:SurvivorRatio=8:设置新生代 Eden 和 Survivor 比例为 8:2;
–XX:+UseParNewGC:指定使用 ParNew + Serial Old 垃圾回收器组合;
-XX:+UseParallelOldGC:指定使用 ParNew + ParNew Old 垃圾回收器组合;
-XX:+UseConcMarkSweepGC:指定使用 CMS + Serial Old 垃圾回收器组合;
-XX:+PrintGC:开启打印 gc 信息;
-XX:+PrintGCDetails:打印 gc 详细信息。
java OOM问题排查
出现oom的原因
- 堆内存设置过小,由于请求突然增加,那么我们可以根据我们的场景适量增加堆内存限制;
- 内存泄漏导致,一般只存在业务上的泄漏(长生命周期对象持有短生命周期的引用),理论上jvm不会出现泄漏,这种的话一般是代码bug,定位到具体代码即可
在做服务器端开发的时候,经常会遇到服务由于内存溢出挂掉的情况,这种情况的发生一般来说是很难预期的,也比较难以重现,对于这种问题,一般可以通过记录内存溢出时候的堆信息来排查。
1、首先可以查看服务器运行日志以及项目记录的日志,捕捉到内存溢出异常
2、如果程序挂掉了,但是没有找到任何这个操作的日志记录。这时查看一下/var/log/messages文件。messages
3、捕获到OOM异常的时候就会生成一个java.hprof(堆内存快照)文件
4、当然阿里的(Arthas(阿尔萨斯)java诊断工具也是可以的
通过找出哪些类占用最多或者通过部分代码执行前和执行后的dump对比,就能找出没有释放的对象,定位到代码
2、如果程序挂掉了,但是没有找到任何这个操作的日志记录。这时查看一下/var/log/messages文件。messages
3、捕获到OOM异常的时候就会生成一个java.hprof(堆内存快照)文件
4、当然阿里的(Arthas(阿尔萨斯)java诊断工具也是可以的
通过找出哪些类占用最多或者通过部分代码执行前和执行后的dump对比,就能找出没有释放的对象,定位到代码
jvm问题排查方法总结
内存
dump快照文件
生成dump几种方式
手动
使用 jmap 命令生成 dump 文件
jmap -dump:live,format=b,file=c:\dump\heap.hprof [pid]
当没响应时可通过加-F强制生成dump文件
当没响应时可通过加-F强制生成dump文件
使用jdk可视化工具(VisualVM)打开生成的heap1.hprof文件
使用 jcmd 命令生成 dump 文件
jcmd <pid> GC.heap_dump c:\dump\heap.hprof
自动
使用 JVM 参数获取 dump 文件
当oom时生成 -XX:+HeapDumpOnOutOfMemoryError
存放路径:-XX:HeapDumpPath=/tmp/heapdump.hprof
存放路径:-XX:HeapDumpPath=/tmp/heapdump.hprof
如何分析
dump文件分析工具
Jconsole:jdk自带,功能简单,但是可以在系统有一定负荷的情况下使用。对垃圾回收算法有很详细的跟踪。
JProfiler:商业软件,功能强大。
VisualVM:JDK自带,功能强大,与JProfiler类似。
MAT:MAT(Memory Analyzer Tool),一个基于Eclipse的内存分析工具。
很多时候上面的工具并不能明显找到哪个类占用内存最多,此时这个工具就能找到
很多时候上面的工具并不能明显找到哪个类占用内存最多,此时这个工具就能找到
free-m查看虚拟机内存使用情况,当然有时候设置了初始=最大,看不出来
cpu
使用top查看cpu情况,或者其他监控工具,如果发现cpu使用过高,需要找到哪个线程占用的或者说哪个地方
查看内存跟cpu、gc情况,定位到问题关键。有的设置了初始大小=最大大小,所以需要查看gc的情况(内存一直一样多,但实际上一直在gc导致服务响应慢)
表现
服务响应慢或者干脆跟挂了一样
oom造成服务挂了
一直在gc导致服务响应不了
cup占用过高,没cpu资源来响应服务
oom异常
频繁创建对象,一般为bug,需要优化或更改
内存溢出过多导致oom
堆外内存溢出
cpu负载飙升
单纯的发现gc频繁需要更改内存分配参数
通过加大内存,优化各区的比例、更换垃圾收集器等方式
查看jvm各个区域的内存大小合理进行优化
并发编程
操作系统底层工作的整体认识
冯诺依曼计算机模型
概述
计算机在运行时,先从内存中取出第一条指令,通过控制器的译码,按指令的要求,从存储器中取出数据进行指定的运算和逻辑操作等加工,然后再按地址把结果送到内存中去。接下来,再取出第二条指令,在控制器的指挥下完成规定操作。依此进行下去。直至遇到停止指令。
程序与数据一样存贮,按程序编排的顺序,一步一步地取出指令,自动地完成指令规定的操作是计算机最基本的工作模型。这一原理最初是由美籍匈牙利数学家冯.诺依曼于1945年提出来的,故称为冯.诺依曼计算机模型
计算机五大核心组成部分
控制器(Control):是整个计算机的中枢神经,其功能是对程序规定的控制信息进行解释(比如将程序的指令解释成计算机的设备能识别的信息),根据其要求进行控制,调度程序、数据、地址,协调计算机各部分工作及内存与外设的访问等
运算器(Datapath):运算器的功能是对数据进行各种算术运算和逻辑运算,即对数据进行加工处理
存储器(Memory):存储器的功能是存储程序、数据和各种信号、命令等信息,并在需要时提供这些信息
输入(Input system):输入设备是计算机的重要组成部分,输入设备与输出设备合称为外部设备,简称外设,输入设备的作用是将程序、原始数据、文字、字符、控制命令或现场采集的数据等信息输入到计算机。常见的输入设备有键盘、鼠标器、光电输入机、磁带机、磁盘机、光盘机等
输出(Output system):输出设备与输入设备同样是计算机的重要组成部分,它把外算机的中间结果或最后结果、机内的各种数据符号及文字或各种控制信号等信息输出出来。微机常用的输出设备有显示终端CRT、打印机、激光印字机、绘图仪及磁带、光盘机等
CPU指令结构
控制单元
控制单元是整个CPU的指挥控制中心,由指令寄存器IR(Instruction Register)、指令译码器ID(Instruction Decoder)和 操作控制器OC(Operation Controller) 等组成,对协调整个电脑有序工作极为重要。它根据用户预先编好的程序,依次从存储器中取出各条指令,放在指令寄存器IR中,通过指令译码(分析)确定应该进行什么操作,然后通过操作控制器OC,按确定的时序,向相应的部件发出微操作控制信号。操作控制器OC中主要包括:节拍脉冲发生器、控制矩阵、时钟脉冲发生器、复位电路和启停电路等控制逻辑
运算单元
运算单元是运算器的核心。可以执行算术运算(包括加减乘数等基本运算及其附加运算)和逻辑运算(包括移位、逻辑测试或两个值比较)。相对控制单元而言,运算器接受控制单元的命令而进行动作,即运算单元所进行的全部操作都是由控制单元发出的控制信号来指挥的,所以它是执行部件
存储单元
存储单元包括 CPU 片内缓存Cache和寄存器组,是 CPU 中暂时存放数据的地方,里面保存着那些等待处理的数据,或已经处理过的数据,CPU 访问寄存器所用的时间要比访问内存的时间短。 寄存器是CPU内部的元件,寄存器拥有非常高的读写速度,所以在寄存器之间的数据传送非常快。采用寄存器,可以减少 CPU 访问内存的次数,从而提高了 CPU 的工作速度。寄存器组可分为专用寄存器和通用寄存器。专用寄存器的作用是固定的,分别寄存相应的数据;而通用寄存器用途广泛并可由程序员规定其用途
CPU缓存结构
cpu三级缓存
L1 Cache,分为数据缓存和指令缓存,逻辑核独占
L2 Cache,物理核独占,逻辑核共享
L3 Cache,所有物理核共享
- 存储器存储空间大小:内存>L3>L2>L1>寄存器;
- 存储器速度快慢排序:寄存器>L1>L2>L3>内存;
还有一点值得注意的是:缓存是由最小的存储区块-缓存行(cacheline)组成,缓存行大小通常为64byte
- 比如你的L1缓存大小是512kb,而cacheline = 64byte,那么就是L1里有512 * 1024/64个cacheline】
CPU读取存储器数据过程
- CPU要取寄存器X的值,只需要一步:直接读取。
- CPU要取L1 cache的某个值,需要1-3步(或者更多):把cache行锁住,把某个数据拿来,解锁,如果没锁住就慢了。
- CPU要取L2 cache的某个值,先要到L1 cache里取,L1当中不存在,在L2里,L2开始加锁,加锁以后,把L2里的数据复制到L1,再执行读L1的过程,上面的3步,再解锁。
- CPU取L3 cache的也是一样,只不过先由L3复制到L2,从L2复制到L1,从L1到CPU。
- CPU取内存则最复杂:通知内存控制器占用总线带宽,通知内存加锁,发起内存读请求,等待回应,回应数据保存到L3(如果没有就到L2),再从L3/2到L1,再从L1到CPU,之后解除总线锁定
CPU为何要有高速缓存
CPU在摩尔定律的指导下以每18个月翻一番的速度在发展,然而内存和硬盘的发展速度远远不及CPU。这就造成了高性能的内存和硬盘价格及其昂贵。然而CPU的高度运算需要高速的数据。为了解决这个问题,CPU厂商在CPU中内置了少量的高速缓存以解决I\O速度和CPU运算速度之间的不匹配问题。(也就是尽量去用cpu,不然数据读的太慢,cpu的使用得不到充分发挥)
在CPU访问存储设备时,无论是存取数据或存取指令,都趋于聚集在一片连续的区域中,这就被称为局部性原理(使用所有存储单元)。分为下面两个:
- 时间局部性(Temporal Locality):如果一个信息项正在被访问,那么在近期它很可能还会被再次访问。比如循环、递归、方法的反复调用等。
- 空间局部性(Spatial Locality):如果一个存储器的位置被引用,那么他附近的位置也会被引用(把那相邻的一整块数据都拿过去,而不是拿一个就只拿一个)。比如顺序执行的代码、连续创建的两个对象、数组等。
案例分析:因为一行一行的加的时候,会将附近都移到cpu中去。从而跟内存交互的次数大大减少。而一列一列的来的时候,虽然也会拿相邻的数据,但[1][0]跟[2][0]不相邻,所以交互次数很多
带有高速缓存的CPU执行计算的流程
- 程序以及数据被加载到主内存
- 指令和数据被加载到CPU的高速缓存
- CPU执行指令,把结果写到高速缓存
- 高速缓存中的数据写回主内存
CPU运行安全等级
CPU有4个运行级别,分别为:ring0、ring1、ring2、ring3
Linux与Windows只用到了2个级别:ring0、ring3,操作系统内部内部程序指令通常运行在ring0级别,操作系统以外的第三方程序运行在ring3级别,第三方程序如果要调用操作系统内部函数功能,由于运行安全级别不够,必须切换CPU运行状态,从ring3切换到ring0,然后执行系统函数,说到这里相信大家明白为什么JVM创建线程,线程阻塞唤醒是重型操作了,因为CPU要切换运行状态
JVM创建线程CPU的工作过程
- step1:CPU从ring3切换ring0创建线程
- step2:创建完毕,CPU从ring0切换回ring3
- step3:线程执行JVM程序
- step4:线程执行完毕,销毁还得切会ring0
操作系统内存管理
执行空间保护
Linux为内核代码和数据结构预留了几个页框,这些页永远不会被转出到磁盘上。用户程序运行在用户方式下,而系统调用运行在内核方式下。在这两种方式下所用的堆栈不一样:用户方式下用的是一般的堆栈(用户空间的堆栈),而内核方式下用的是固定大小的堆栈(内核空间的堆栈,一般为一个内存页的大小),即每个进程与线程其实有两个堆栈,分别运行与用户态与内核态
内核线程模型跟用户线程模型
内核线程(KLT):系统内核管理线程(KLT),内核保存线程的状态和上下文信息,线程阻塞不会引起进程阻塞。在多处理器系统上,多线程在多处理器上并行运行。线程的创建、调度和管理由内核完成,效率比ULT要慢,比进程操作快
用户线程(ULT):用户程序实现,不依赖操作系统核心,应用提供创建、同步、调度和管理线程的函数来控制用户线程。不需要用户态/内核态切换,速度快。内核对ULT无感知,线程阻塞则进程(包括它的所有线程)阻塞。
jvm是采用的内核线程模型
进程与线程
进程:现代操作系统在运行一个程序时,会为其创建一个进程;例如,启动一个Java程序,操作系统就会创建一个Java进程。进程是OS(操作系统)资源分配的最小单位
线程:是OS(操作系统)调度CPU的最小单元,也叫轻量级进程(Light Weight Process),在一个进程里可以创建多个线程,这些线程都拥有各自的计数器、堆栈和局部变量等属性,并且能够访问共享的内存变量。CPU在这些线程上高速切换,让使用者感觉到这些线程在同时执行,即并发的概念,相似的概念还有并行
线程上下文切换过程:每个线程分配到cpu时间片的时候,就真正执行,当这个时间片走完了就停止(可以理解为执行完时间片的时间),等着下次分配时间片(如果线程还未结束)
并发跟并行:
- 并发是指一个处理器同时处理多个任务。
- 并行是指多个处理器或者是多核的处理器同时处理多个不同的任务。
虚拟机指令集架构
栈指令集架构
- 设计和实现更简单,适用于资源受限的系统;
- 避开了寄存器的分配难题:使用零地址指令方式分配;
- 指令流中的指令大部分是零地址指令,其执行过程依赖于操作栈,指令集更小,编译器容易实现;
- 不需要硬件支持,可移植性更好,更好实现跨平台
寄存器指令集架构
- 典型的应用是x86的二进制指令集:比如传统的PC以及Android的Davlik虚拟机。
- 指令集架构则完全依赖硬件,可移植性差。
- 性能优秀和执行更高效。
- 花费更少的指令去完成一项操作。
- 在大部分情况下,基于寄存器架构的指令集往往都以一地址指令、二地址指令和三地址指令为主,而基于栈式架构的指令集却是以零地址指令为主
Java符合典型的栈指令集架构特征
简单理解,比如:a=1;b=2;要计算a+b=?,在寄存器指令中,直接把a跟b拿到cpu中,计算出结果,再返回。但是在栈指令集架构的话,需要先将a压入线程栈中,然后再出栈放到局部变量表中,b也是这样,最后再拉到cpu中计算,每次入栈跟出栈都是需要cpu去做(就是前面说的在局部变量表跟操作数栈中)
并发编程之JMM&volatile
JMM(Java内存模型)
什么是JMM模型?
Java内存模型(Java Memory Model简称JMM)是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间,就是虚拟机栈),用于存储线程私有的数据,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝,前面说过,工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。
java内存模型为一个抽象的概念,它描述着工作内存跟主内存之间的交互方式:线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量
主内存与工作内存
主内存
主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行访问可能会发生线程安全问题
工作内存
主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝),每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题
Java内存模型与硬件内存架构的关系
工作内存跟主内存可能存在cpu寄存器、CPU缓存区、主内存中任何一个地方,只是jvm逻辑划分成了不同的区域,并不是真正对应着硬件的内存
JMM存在的必要性
在早期的c、c++语言中是没有定义内存模型的,其行为依赖于处理器本身的内存一致性模型,但不同的处理器处理结果可能会不一样,Java设计之初就引入了线程的概念,以充分利用现代处理器的计算能力,这既带来了强大、灵活的多线程机制,也带来了线程安全等令人混淆的问题,为了解决这个问题,Java内存模型(Java Memory Model,JMM)为我们提供了一个在纷乱之中达成一致的指导准则。规定了什么样的执行是符合规范的
java内存模型围绕着在并发过程中如何处理可见性、原子性、有序性这三个特性而建立的模型。
java内存模型围绕着在并发过程中如何处理可见性、原子性、有序性这三个特性而建立的模型。
数据同步八大原子操作
八大原子操作
lock(锁定) /lɑːk/:作用于主内存的变量,把一个变量标记为一条线程独占状态
unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
load(载入)/loʊd/:作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎
assign(赋值)/əˈsaɪn/:作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量
store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作
write(写入):作用于工作内存的变量,它把store操作从工作内存中的一个变量的值传送到主内存的变量中
同步规则分析
- 1)不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中
- 2)一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或者assign)的变量。即就是对一个变量实施use和store操作之前,必须先自行load和assign操作。
- 3)一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现。
- 4)如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量之前需要重新执行load或assign操作初始化变量的值。
- 5)如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
- 6)对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)
并发编程的可见性,原子性与有序性问题
原子性
对于32位系统的来说,64位的long类型数据和double类型数据(对于基本数据类型,byte,short,int,float,boolean,char读写是原子操作),它们的读写并非原子性的。这种情况比较少见,而且在目前商用虚拟机中都把64位数据的读写作为原子性操作
- X=10; //原子性(简单的读取、将数字赋值给变量)
- Y = x; //非原子操作,变量之间的相互赋值,因为需要两步:读x跟写y
- X++; //非原子操作,对变量进行计算操作
- X = x+1;//非原子操作,需要先读取x后计算在写入
可见性
可见性指的是当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值。对于串行程序来说,可见性是不存在的,因为我们在任何一个操作中修改了某个变量的值,后续的操作中都能读取这个变量值,并且是修改过的新值
但在多线程环境中可就不一定了,前面我们分析过,由于线程对共享变量的操作都是线程拷贝到各自的工作内存进行操作后才写回到主内存中的,这就可能存在一个线程A修改了共享变量x的值,还未写回主内存时,另外一个线程B又对主内存中同一个共享变量x进行操作,但此时A线程工作内存中共享变量x对线程B来说并不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题,另外指令重排以及编译器优化也可能导致可见性问题,通过前面的分析,我们知道无论是编译器优化还是处理器优化的重排现象,在多线程环境下,确实会导致程序轮序执行的问题,从而也就导致可见性问题
有序性
有序性是指对于单线程的执行代码,我们总是认为代码的执行是按顺序依次执行的,这样的理解并没有毛病,毕竟对于单线程而言确实如此,但对于多线程环境,则可能出现乱序现象,因为程序编译成机器码指令后可能会出现指令重排现象,重排后的指令与原指令的顺序未必一致,要明白的是,在Java程序中,倘若在本线程内,所有操作都视为有序行为,如果是多线程环境下,一个线程中观察另外一个线程,所有操作都是无序的,前半句指的是单线程内保证串行语义执行的一致性,后半句则指指令重排现象和工作内存与主内存同步延迟现象
JMM如何解决原子性&可见性&有序性问题
原子性问题
除了JVM自身提供的对基本数据类型读写操作的原子性外,可以通过 synchronized和Lock实现原子性。因为synchronized和Lock能够保证任一时刻只有一个线程访问该代码块。
可见性问题
volatile /ˈvɒlətaɪl/ 关键字保证可见性。当一个共享变量被volatile修饰时,它会保证修改的值立即被其他的线程看到,即修改的值立即更新到主存中,当其他线程需要读取时,它会去内存中读取新值。synchronized和Lock也可以保证可见性,因为它们可以保证任一时刻只有一个线程能访问共享资源,并在其释放锁之前将修改的变量刷新到内存中
- synchronized和Lock要注意共享变量只能在锁内操作,锁保证的可见性是在进入锁的时候的最新数据,如果错误的在非同步块操作了共享数据,同样会导致不可见性
有序性问题
在Java里面,可以通过volatile关键字来保证一定的“有序性”。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性
指令重排序
java语言规范规定JVM线程内部维持顺序化语义。即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。指令重排序的意义是什么?JVM能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能
编译器和处理器都能执行指令重排优化
as-if-serial语义
不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序
happens-before 原则
- 程序顺序原则,即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。
- 锁规则 ,解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。
- volatile规则, volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。
- 线程启动规则 ,线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见
- 传递性, A先于B ,B先于C 那么A必然先于C
- 线程终止规则, 线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回后,线程B对共享变量的修改将对线程A可见。
- 线程中断规则 ,对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。
- 对象终结规则,对象的构造函数执行,结束先于finalize()方法
volatile
volatile的可见性
保证被volatile修饰的共享变量对所有线程总是可见的,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总是可以被其他线程立即得知。原理是通过MESI协议实现可见性
volatile无法保证原子性
只能通过锁来串行化解决
volatile禁止重排优化
volatile关键字另一个作用就是禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象,关于指令重排优化前面已详细分析过,这里主要简单说明一下volatile是如何实现禁止指令重排优化的。先了解一个概念,内存屏障(Memory Barrier)
volatile是怎么实现禁止指令重排的
硬件层的内存屏障
Intel硬件提供了一系列的内存屏障,主要有:
- 1. lfence,是一种Load Barrier 读屏障
- 2. sfence, 是一种Store Barrier 写屏障
- 3. mfence, 是一种全能型的屏障,具备ifence和sfence的能力
- 4. Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。它后面可以跟ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG等指令
通过内存屏障实现可见性和禁止重排优化
内存屏障,又称内存栅栏,是一个CPU指令,它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。 Memory Barrier的另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。总之,volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。
典型问题:单例中的DCL(双重检查锁)指令重排例子,正是因为指令重排的原因可能导致线程拿到未初始化好的对象
多核CPU多级缓存一致性协议MESI
多核CPU的情况下有多个一级缓存(缓存一致性只是针对于L1缓存,因为其他缓存都需要拷贝到L1才能被cpu使用),如何保证缓存内部数据的一致,不让系统数据混乱。这里就引出了一个一致性的协议MESI。多级缓存一致性协议各个厂家的cpu都有自己的实现,不过主要还是MESI协议
MESI协议缓存状态
- Cache line四种状态
M 已修改 (Modified)
描述:该Cache line有效,数据已被修改了,和内存中的数据不一致,数据只存在于本Cache中。
监听任务:缓存行必须时刻监听所有试图读该缓存行相对在主内存的操作,这种操作必须在缓存将该缓存行写回主内存并将状态变成S(共享)状态之前被延迟执行。
E 独享、互斥 (Exclusive)
描述:该Cache line有效,数据和内存中的数据一致,数据只存在于本Cache中。
监听任务:缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S(共享)状态。
S 共享 (Shared)
描述:该Cache line有效,数据和内存中的数据一致,数据存在于很多Cache中。
监听任务:缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。
I 已失效 (Invalid)
描述:该Cache line无效。
监听任务:无
案例理解
1:线程A读取到主内存的数据a=0;此时a变量在该cpu核上为E(独享)状态。
2:线程B读取到主内存的数据a=0;此时因为a已经在线程A上,所以他们的状态都是S(共享)状态。
3:线程A跟线程B同时去修改变量a;他们一开始都会写进自己的缓存中并且成功,接着都写回主内存,然后再总线那里就会通过总线裁决的方式选举哪个去写到主内存中去,比如这里A写成功,然后B的数据就会被失效并丢弃。
4:如果保证了可见性的情况下,那么B线程会感知到此时a的数据为a=1了,所以就会重新load到cpu缓存中去,也就变成了S(共享)状态了
前期技术不好的时候采用的是总线锁的方式:当两个cpu要操作同一个数据的时候,只能其中一个获取到总线锁,然后其他的cpu就不能访问我们的主内存(cpu访问主内存中间是需要通过几种总线的),也就操作不了。这样是没办法发挥cpu多核的效率
总线嗅探机制
就是数据在缓存跟主内存之间的交互都需要经过总线,当cpu从缓存写回数据到主内存的时候其他存在的cache会感知到并且重新load到缓存中,当cpu从主内存读取数据的时候,如果其他cache存在则变更为s共享状态
缓存行伪共享
什么是伪共享?
CPU缓存系统中是以缓存行(cache line)为单位存储的。目前主流的CPU Cache 的 Cache Line 大小都是64Bytes。在多线程情况下,如果需要修改“共享同一个缓存行的变量”,就会无意中影响彼此的性能,这就是伪共享(False Sharing)
- 举个例子: 现在有2个int型变量 a 、b,如果有t1在访问a,t2在访问b,而a与b刚好在同一个cache line中,此时t1先修改a,将导致b被刷新
怎么解决伪共享?
Java8中新增了一个注解:@sun.misc.Contended。加上这个注解的类会自动补齐缓存行(也就不会存在多个变量共享一个CacheLine),需要注意的是此注解默认是无效的,需要在jvm启动时设置 -XX:-RestrictContended 才会生效。
MESI优化和他们引入的问题
CPU切换状态阻塞
问题引发:比如你需要修改本地缓存中的一条信息,那么你必须将I(无效)状态通知到其他拥有该缓存数据的CPU缓存中,并且等待确认。等待确认的过程会阻塞处理器,这会降低处理器的性能。因为这个等待远远比一个指令的执行时间长的多
解决方案:存储缓存(Store/stɔːr/ Bufferes)
也就是说当cpu修改后会先将修改后的值放到store buffer中,然后发送I失效状态到其他cpu,当其他cpu都回应后再将buffer中的值写到L1-cache中去,最后再写入主内存中。还有在其他cpu中也不是立马就失效掉x,而是在收到I失效命令的时候先将x放到失效队列queue中去(此时CPU缓存中可能还存在x这个值)放完立马回应(放到队列再回应比真正失效再回应更快),至于什么时候真正的失效就不用纠结过深。(比如queue不够用啊,到达多少量,cpu缓存不够用的时候这种)
Store Bufferes的风险
第一、就是处理器会尝试从存储缓存(Store buffer)中读取值,但它还没有进行提交。这个的解决方案称为Store Forwarding(存储传递),它使得加载的时候,如果存储缓存中存在,则进行返回。也就是先会从store buffer中取值,不存在才去主内存中拿
第二、保存到主内存中去什么时候会完成,这个并没有任何保证
读写屏障处理store bufferes和失效queue
写屏障:在更新数据之前必须将所有存储缓存(store buffer)中的指令执行完毕
读屏障:在读取之前将所有失效队列中关于该数据的指令执行完毕
并发编程之synchronized /ˈsɪŋkrə naɪ zd/
如何解决线程并发安全问题?
实际上,所有的并发模式在解决线程安全问题时,采用的方案都是序列化访问临界资源。即在同一时刻,只能有一个线程访问临界资源,也称作同步互斥访问。Java 中,提供了两种方式来实现同步互斥访问:synchronized 和 Lock。同步器的本质就是加锁
synchronized介绍
synchronized内置锁是一种对象锁(锁的是对象而非引用),作用粒度是对象,可以用来实现对临界资源的同步互斥访问,是可重入的
加锁的方式
同步实例方法,锁是当前实例对象
- public synchronized void method()
同步类方法,锁是当前类对象
- public static synchronized void method()
同步代码块,锁是括号里面的对象
- synchronized (obj)
synchronized底层原理
synchronized是基于JVM内置锁【内部对象Monitor(监视器锁)】实现,基于进入与退出Monitor对象实现方法与代码块同步,而监视器锁的实现依赖底层操作系统的Mutex/mju tes/ lock(互斥锁)实现,它是一个重量级锁,性能较低。当然,JVM内置锁在1.5之后版本做了重大的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销,,内置锁synchronized的并发性能已经基本与Lock持平
synchronized编译后会在首尾加上monitorenter/ˈentər/ 和 monitorexit/ˈeksɪt/ 两条指令,监视器锁尝试去monitor.enter,如果进入失败则放入synchronizequeue中重新尝试,获取锁的执行完会执行monitor.exit释放锁(可以简单理解monitor.enter跟monitor.exit为操作系统的两条指令,监视器锁负责去调用,也就是去获取锁与释放锁,这两个指令为操作系统的mutex lock互斥锁),从而实现同步逻辑;简单说就是会把同步代码放到monitor对象里面执行,而monitor中使用操作系统的mutex串行化执行
每个同步对象都有一个自己的Monitor(监视器锁),加锁过程如图所示
就好比我们通过lock加锁,加锁成功则继续往下执行,加锁失败则放入等待队列中,而这里的加锁动作则依赖于操作系统的mutex lock
每个同步对象都有一个自己的Monitor(监视器锁),加锁过程如图所示
就好比我们通过lock加锁,加锁成功则继续往下执行,加锁失败则放入等待队列中,而这里的加锁动作则依赖于操作系统的mutex lock
Monitor监视器锁
任何一个对象都有一个Monitor与之关联,当且一个Monitor被持有后,它将处于锁定状态。Synchronized在JVM里的实现都是 基于进入和退出Monitor对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的MonitorEnter和MonitorExit指令来实现
monitorenter:每个对象都是一个监视器锁(monitor)。当monitor被占用时就处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
- 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;
- 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1;所以说重量级锁其实也是支持重入的
- 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权
monitorexit:执行monitorexit的线程必须是objectref所对应的monitor的所有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权
Synchronized的语义底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象(锁的释放与等待那不得需要monitor来调用c++逻辑),这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因
什么是monitor?
在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。也就是通常说Synchronized的对象锁,MarkWord锁标识位为10,其中指针指向的是Monitor对象的起始地址
总结:在升级到重量级锁的时候(不管什么锁的状态都是能在对象头表现出来),在每个java对象中都有个Monitor对象(c++对象)的指针放在该java对象头的Mark word中,然后Synchronize锁便是通过这个Monitor对象实现的:monitor对象实现类为ObjectMonitor,ObjectMonitor类中存在很多属性,其中_count(一个计数器,当等于0的时候其他线程就能进入)、_owner(用来放当前持有锁的线程)、_WaitSet(处于wait状态的线程)、_EntryList(处于等待锁block状态的线程)尤为关键。在Synchronize实现锁中,不管哪种写法都是通过给同一对象加锁实现,加锁的对象只有一个,也就是c++里面的ObjectMonitor对象也只有一个,通过上面说的关键几个属性控制线程的锁。监视器锁的实现依赖底层操作系统的Mutex lock(互斥锁)实现,它是一个重量级锁,性能较低。
- 偏向锁跟轻量级锁通过CAS(比较-替换)去修改对象头的mark word实现,重量级锁通过操作系统的mutex lock互斥锁实现
CAS(比较-替换)原理
从名字上就可以知道是先比较然后相等才可以修改:会把原来的值记录在一个地方,当我修改了后,会把主内存的值跟记录的原来值比较,相等才可以修改,不然就丢弃,重新拿新值操作
锁的膨胀升级过程
Synchronize锁的膨胀升级过程
锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。从JDK 1.6 中默认是开启偏向锁和轻量级锁的,可以通过-XX:-UseBiasedLocking来禁用偏向锁
图大概理解:当线程访问同步代码块的时候,会去检查mark work的锁状态,
偏向锁:如果是偏向锁,那就比较下mark word中记录的是否为当前线程id,是的话就执行,不是的话就尝试通过CAS操作去获取偏向锁,如果获取偏向锁失败说明需要升级为轻量级锁了,等待原持有锁的线程到达安全点后,如果原持有锁的线程已经退出同步代码块或未活动状态(如等待),则并不会升级锁而是重新尝试获取偏向锁,如果原持有锁的线程未退出同步块,则升级为轻量级锁,并且会让原持有锁的线程拿到轻量级锁,唤醒后继续执行;
轻量级锁:分配锁记录对象并将mark word拷贝到锁记录对象中去(栈上分配),尝试把对象头的mark word指向该锁记录对象(当前线程的锁记录对象),指向失败的话就开始自旋。如果自旋失败一定次数后还没分配到锁就需要升级为重量级锁了。
重量级锁:mark word中的monitor指针指向ObjectMonitor对象,通过操作系统的Mutex lock(互斥锁)实现锁机制,它是一个重量级锁,性能较低,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。
偏向锁:如果是偏向锁,那就比较下mark word中记录的是否为当前线程id,是的话就执行,不是的话就尝试通过CAS操作去获取偏向锁,如果获取偏向锁失败说明需要升级为轻量级锁了,等待原持有锁的线程到达安全点后,如果原持有锁的线程已经退出同步代码块或未活动状态(如等待),则并不会升级锁而是重新尝试获取偏向锁,如果原持有锁的线程未退出同步块,则升级为轻量级锁,并且会让原持有锁的线程拿到轻量级锁,唤醒后继续执行;
轻量级锁:分配锁记录对象并将mark word拷贝到锁记录对象中去(栈上分配),尝试把对象头的mark word指向该锁记录对象(当前线程的锁记录对象),指向失败的话就开始自旋。如果自旋失败一定次数后还没分配到锁就需要升级为重量级锁了。
重量级锁:mark word中的monitor指针指向ObjectMonitor对象,通过操作系统的Mutex lock(互斥锁)实现锁机制,它是一个重量级锁,性能较低,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。
- 不管什么锁都是串行化线程,重量级锁之所以慢是因为需要调用操作系统级别(内核态)的指令,而且会挂起阻塞的线程,来回切换。偏向锁、轻量锁并不会,大部分场景都不会升级重量锁,在偏向锁跟轻量锁基本都能满足了
Synchronize锁优化
偏向锁
偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。下面我们接着了解轻量级锁。
- 默认开启偏向锁
- 开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
- 关闭偏向锁:-XX:-UseBiasedLocking
轻量级锁
倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁(因为很容易自旋达到最大值)
自旋锁
轻量级锁失败后(只有这种情况存在自旋),虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了
消除锁
消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间,如下StringBuffer的append是一个同步方法,但是在add方法中的StringBuffer属于一个局部变量,并且不会被其他线程所使用,因此StringBuffer不可能存在共享资源竞争的情景,JVM会自动将其锁消除。锁消除的依据是逃逸分析的数据支持。
- 锁消除,前提是java必须运行在server模式(server模式会比client模式作更多的优化),同时必须开启逃逸分析
- :-XX:+DoEscapeAnalysis 开启逃逸分析
- -XX:+EliminateLocks 表示开启锁消除
锁粗化
通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽可能短,但是大某些情况下,一个程序对同一个锁不间断、高频地请求、同步与释放,会消耗掉一定的系统资源,因为锁的请求、同步与释放本身会带来性能损耗,这样高频的锁请求就反而不利于系统性能的优化了,虽然单次同步操作的时间可能很短。锁粗化就是告诉我们任何事情都有个度,有些情况下我们反而希望把很多次锁的请求合并成一个请求,以降低短时间内大量锁请求、同步、释放带来的性能损耗
Java并发编程基础
线程简介
线程与协程
进程与线程
对操作系统来说,线程是最小的执行单元,进程是最小的资源管理单元
一个线程只可以属于一个进程,但一个进程能包含多个线程
线程是一种轻量级的进程,与进程相比,线程给操作系统带来侧创建、维护、和管理的负担要轻,意味着线程的代价或开销比较小
线程无地址空间,它包括在进程的地址空间里
协程
协程 (纤程,用户级线程),目的是为了追求最大力度的发挥硬件性能和提升软件的速度,协程基本原理是:在某个点挂起当前的任务,并且保存栈信息,去执行另一个任务;等完成或达到某个条件时,再还原原来的栈信息并继续执行(整个过程线程不需要上下文切换)。一个内核线程下面创建多个协程,多个协程在一个操作系统线程运行,协程的阻塞与运行是由用户态控制,所以这样就压榨了cpu,而且这样就不存在并发问题
线程优先级与时间片
现代操作系统基本采用时分的形式调度运行的线程,操作系统会分出一个个时间片,线程会分配到若干时间片,当线程的时间片用完了就会发生线程调度,并等待着下次分配。线程分配到的时间片多少也就决定了线程使用处理器资源的多少,而线程优先级就是决定线程需要多或者少分配一些处理器资源的线程属性
- 注意:线程优先级不能作为程序正确性的依赖,因为操作系统可以完全不用理会Java线程对于优先级的设定
线程的状态
初始(NEW):新创建了一个线程对象,但还没有调用start()方法
运行(RUNNABLE):Java线程将操作系统中的就绪(ready)和运行中(running)两种状态笼统的称为“运行”。
- 线程对象创建后,其他线程(比如main线程)调用了该对象的start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取CPU的使用权,此时处于就绪状态(ready)。就绪状态的线程在获得CPU时间片后变为运行中状态(running)
阻塞(BLOCKED):表示线程阻塞于锁。
等待(WAITING):进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。
超时等待(TIMED_WAITING):该状态不同于WAITING,它可以在指定的时间后自行返回
终止(TERMINATED):表示该线程已经执行完毕
线程的各种状态之间的转换图
注意:Java将操作系统中的运行和就绪两个状态合并称为运行状态。阻塞状态是线程阻塞在进入synchronized关键字修饰的方法或代码块时的状态,但是阻塞在java.concurrent包中Lock接口的线程状态却是等待状态,因为java.concurrent包中Lock接口对于阻塞的实现均使用了LockSupport类中的相关方法
守护线程与非守护线程
守护线程(Daemon线程)
主要被用作程序中后台调度以及支持性工作。这意味着,当一个Java虚拟机中不存在非Daemon线程的时候,Java虚拟机将会退出。可以通过调用Thread.setDaemon(true)将线程设置为Daemon线程。比如垃圾回收线程就是一个很称职的守护者,并且这种线程并不属于程序中不可或缺的部分。因此,当所有的非守护线程结束时,程序也就终止了,同时会杀死进程中的所有守护线程
非守护线程
守护线程和用户线程的没啥本质的区别,唯一的不同之处就在于虚拟机的离开:如果用户线程(非守护线程)已经全部退出运行了,只剩下守护线程存在了,虚拟机也就退出了。 因为没有了被守护者,守护线程也就没有工作可做了,也就没有继续运行程序的必要了
线程操作
中断线程--interrupt()
中断可以理解为线程的一个标识位属性,它表示一个运行中的线程是否被其他线程进行了中断操作。中断好比其他线程对该线程打了个招呼,其他线程通过调用该线程的interrupt() /ˌɪntəˈrʌpt/ 方法对其进行中断操作
线程通过检查自身是否被中断来进行响应,线程通过方法isInterrupted()【是否中断过,false表示未中断】来进行判断是否被中断,也可以调用静态方法Thread.interrupted()对当前线程的中断标识位进行复位。如果该线程已经处于终结状态,即使该线程被中断过,在调用该线程对象的isInterrupted()时依旧会返回false
从Java的API中可以看到,许多声明抛出InterruptedException的方法(例如Thread.sleep(longmillis)方法)这些方法在抛出InterruptedException之前,Java虚拟机会先将该线程的中断标识位清除,然后抛出InterruptedException,此时调用isInterrupted()方法将会返回false
过期API-suspend()、resume()和stop()
暂停、恢复和停止操作对应在线程Thread的API就是suspend()(暂停)、resume()(恢复)和stop()(停止),这些API是过期的,也就是不建议使用的
不建议使用的原因主要有:以suspend()方法为例,在调用后,线程不会释放已经占有的资源(比如锁),而是占有着资源进入睡眠状态,这样容易引发死锁问题。同样,stop()方法在终结一个线程时不会保证线程的资源正常释放,通常是没有给予线程完成资源释放工作的机会,因此会导致程序可能工作在不确定状态下
不建议使用的原因主要有:以suspend()方法为例,在调用后,线程不会释放已经占有的资源(比如锁),而是占有着资源进入睡眠状态,这样容易引发死锁问题。同样,stop()方法在终结一个线程时不会保证线程的资源正常释放,通常是没有给予线程完成资源释放工作的机会,因此会导致程序可能工作在不确定状态下
安全地终止线程
前面提到的中断状态是线程的一个标识位,而中断操作是一种简便的线程间交互方式,而这种交互方式最适合用来取消或停止任务。除了中断以外,还可以利用一个boolean变量来控制是否需要停止任务并终止该线程。
线程间通信
wait /weɪt/
wait()方法
调用该方法的线程进入WAITING状态,只有等待另外线程的通知或者被中断才会返回,需要注意,调用wait()方法后,会释放对象的锁
- 中断interrupt()也可以唤醒哦
wait(long)
超时等待一段时间,这里的参数时间是毫秒,也就是等待长达n毫秒,如果没有通知就超时返回
wait(long,int)
对于超时时间更加细粒度的控制,可以达到纳秒
notify
notify()方法
通知一个在对象上等待的线程,使其从wait()方法返回,而返回的前提是该线程获得到了对象的锁
notifyAll()
通知所有等待在该对象上的线程
等待/通知机制,是指一个线程A调用了对象O的wait()方法进入等待状态,而另一个线程B调用了对象O的notify()或者notifyAll()方法,线程A收到通知后从对象O的wait()方法返回,进而执行后续操作。上述两个线程通过对象O来完成交互,而对象上的wait()和notify/notifyAll()的关系就如同开关信号一样,用来完成等待方和通知方之间的交互工作
Thread.join()的使用
如果一个线程A执行了B.join()语句,其含义是:当前线程A等待B线程终止之后才从B.join()返回。线程Thread除了提供join()方法之外,还提供了join(long millis)和join(longmillis,int nanos)两个具备超时特性的方法。这两个超时方法表示,如果线程B在给定的超时时间里没有终止,那么将会从该超时方法中返回
- join方法也是使用的wait跟notifyAll实现
ThreadLocal的使用
ThreadLocal提供了线程内存储变量的能力,这些变量不同之处在于每一个线程读取的变量是对应的互相独立的。通过get和set方法就可以得到当前线程对应的值
yield()
让出cpu的使用权,使线程处于可运行状态,但是,实际中无法保证yield()达到让步目的,因为让步的线程还有可能被线程调度程序再次选中
抽象队列同步器AQS应用Lock详解
介绍
Java并发编程核心在于java.util.concurrent包,而juc当中的大多数同步器实现都是围绕着共同的基础行为,比如等待队列、条件队列、独占获取、共享获取等,而这个行为的抽象就是基于AbstractQueuedSynchronizer /ˈæbstrækt/简称AQS,AQS定义了一套多线程访问共享资源的同步器框架,是一个依赖状态(state /steɪt/)的同步器
- 在AQS中里面用到了CAS去保证只有一个线程能修改state状态成功
ReentrantLock /rɪˈentrənt/
ReentrantLock是一种基于AQS框架的应用实现,是JDK中的一种线程并发访问的同步手段,它的功能类似于synchronized是一种互斥锁,可以保证线程安全。而且它具有比synchronized更多的特性,比如它支持手动加锁与解锁,支持加锁的公平性
//使用ReentrantLock进行同步
ReentrantLock lock = new ReentrantLock(false);//false为非公平锁,true为公平锁
lock.lock() //加锁
//....业务代码
lock.unlock() //解锁
//使用ReentrantLock进行同步
ReentrantLock lock = new ReentrantLock(false);//false为非公平锁,true为公平锁
lock.lock() //加锁
//....业务代码
lock.unlock() //解锁
ReentrantLock如何实现synchronized不具备的公平与非公平性呢
在ReentrantLock内部定义了一个Sync/sɪŋk/ 的内部类,该类继承AbstractQueuedSynchronized,对该抽象类的部分方法做了实现;并且还定义了两个子类:
1、FairSync /fer/公平锁的实现
2、NonfairSync 非公平锁的实现
这两个类都继承自Sync,也就是间接继承了AbstractQueuedSynchronized,所以这一个ReentrantLock同时具备公平与非公平特性。
上面主要涉及的设计模式:模板模式-子类根据需要做具体业务实现
1、FairSync /fer/公平锁的实现
2、NonfairSync 非公平锁的实现
这两个类都继承自Sync,也就是间接继承了AbstractQueuedSynchronized,所以这一个ReentrantLock同时具备公平与非公平特性。
上面主要涉及的设计模式:模板模式-子类根据需要做具体业务实现
AQS具备特性
- 阻塞等待队列
- 共享/独占
- 公平/非公平
- 可重入
- 允许中断
AQS内部维护属性volatile int state (32位) /steɪt/
- state表示资源的可用状态
State三种访问方式: getState()、setState()、compareAndSetState() /kəmˈper/
AQS定义两种资源共享方式
Exclusive-独占,只有一个线程能执行,如ReentrantLock
Share-共享,多个线程可以同时执行,如Semaphore/CountDownLatch
AQS定义两种队列
同步等待队列
AQS当中的同步等待队列也称CLH队列,CLH队列是Craig、Landin、Hagersten三人发明的一种基于双向链表数据结构的队列,是FIFO先进先出线程等待队列,Java中的CLH队列是原CLH队列的一个变种,线程由原自旋机制改为阻塞机制
条件等待队列
Condition是一个多线程间协调通信的工具类,使得某个,或者某些线程一起等待某个条件(Condition),只有当该条件具备时,这些等待线程才会被唤醒,从而重新争夺锁
支持自定义同步器
不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源state的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了,自定义同步器实现时主要实现以下几种方法:
- isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。
- tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
- tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
- tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
- tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。
除了Lock外,Java.util.concurrent当中同步器的实现如Latch,Barrier,BlockingQueue等,都是基于AQS框架实现
AQS同步器也就是资源控制,他们都是基于CAS,都会有通过实现AbstractQueuedSynchronized的对象来控制资源,实现自己想要的效果
AQS同步器也就是资源控制,他们都是基于CAS,都会有通过实现AbstractQueuedSynchronized的对象来控制资源,实现自己想要的效果
原理
同步器依赖内部的同步队列(一个FIFO双向队列)来完成同步状态的管理,当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构造成为一个节点(Node)并将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点中的线程唤醒,使其再次尝试获取同步状态
线程加入队列的过程必须要保证线程安全,因此同步器提供了一个基于CAS的设置尾节点的方法:compareAndSetTail(Node expect,Nodeupdate),它需要传递当前线程“认为”的尾节点和当前节点,只有设置成功后,当前节点才正式与之前的尾节点建立关联
同步队列遵循FIFO,首节点是获取同步状态成功的节点,首节点的线程在释放同步状态时,将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置为首节点,该过程够成功获取到同步状态,因此设置头节点的方法并不需要使用CAS来保证,它只需要将首节点设置成为原首节点的后继节点并断开原首节点的next引用即可
通过调用同步器的acquire(int arg)方法可以获取同步状态,该方法对中断不敏感,也就是由于线程获取同步状态失败后进入同步队列中,后续对线程进行中断操作时,线程不会从同步队列中移出
当前线程获取同步状态并执行了相应逻辑之后,就需要释放同步状态,使得后续节点能够继续获取同步状态。通过调用同步器的release(int arg)方法可以释放同步状态,该方法在释放了同步状态之后,会唤醒其后继节点(进而使后继节点重新尝试获取同步状态)
重入锁
1)线程再次获取锁。锁需要去识别获取锁的线程是否为当前占据锁的线程,如果是,则再次成功获取。
2)锁的最终释放。线程重复n次获取了锁,随后在第n次释放该锁后,其他线程能够获取到该锁。锁的最终释放要求锁对于获取进行计数自增,计数表示当前锁被重复获取的次数,而锁被释放时,计数自减,当计数等于0时表示锁已经成功释放(次数基于state)
2)锁的最终释放。线程重复n次获取了锁,随后在第n次释放该锁后,其他线程能够获取到该锁。锁的最终释放要求锁对于获取进行计数自增,计数表示当前锁被重复获取的次数,而锁被释放时,计数自减,当计数等于0时表示锁已经成功释放(次数基于state)
并发工具类Semaphore、CountDownLatch等
概要:在JDK的并发包里提供了几个非常有用的并发工具类。CountDownLatch、CyclicBarrier和Semaphore工具类提供了一种并发流程控制的手段,Exchanger工具类则提供了在线程间交换数据的一种手段
控制并发线程数的Semaphore /ˈseməfɔːr/
Semaphore 是什么
Semaphore 字面意思是信号量的意思,它的作用是控制访问特定资源的线程数目,底层依赖AQS的状态State,是在生产当中比较常用的一个工具类。比如限流
构造方法:
//permits 表示许可线程的数量
public Semaphore(int permits)
//fair 表示公平性,如果这个设为 true 的话(公平)
public Semaphore(int permits, boolean fair)
//permits 表示许可线程的数量
public Semaphore(int permits)
//fair 表示公平性,如果这个设为 true 的话(公平)
public Semaphore(int permits, boolean fair)
重要方法:
//表示阻塞并获取许可
public void acquire() throws InterruptedException
//表示释放许可
public void release()
//尝试获取锁资源,当超过多少时间后抢不到资源,就返回false,可以通过这个获取超时降级处理
tryAcquire(int args,long timeout, TimeUnit unit)
//表示阻塞并获取许可
public void acquire() throws InterruptedException
//表示释放许可
public void release()
//尝试获取锁资源,当超过多少时间后抢不到资源,就返回false,可以通过这个获取超时降级处理
tryAcquire(int args,long timeout, TimeUnit unit)
CountDownLatch
CountDownLatch是什么
CountDownLatch这个类能够使一个线程等待其他线程完成各自的工作后再执行。例如,应用程序的主线程希望在负责启动框架服务的线程已经启动所有的框架服务之后再执行。简单说就是一个线程需要等待其他线程全部执行完才执行,不然一直阻塞不往下面执行
- Zookeeper分布式锁,Jmeter模拟高并发等
CountDownLatch如何工作
CountDownLatch是通过一个计数器来实现的,计数器的初始值为线程的数量。每当一个线程完成了自己的任务后,计数器的值就会减1。当计数器值到达0时,它表示所有的线程已经完成了任务,然后在闭锁上等待的线程就可以恢复执行任务
CountDownLatch.countDown()
CountDownLatch.await();
CountDownLatch.await();
- CountDownLatch主要有两个方法,当一个或多个线程调用await方法时,这些线程会阻塞。
- 其它线程调用countDown方法会将计数器减1(调用countDown方法的线程不会阻塞)
- 当计数器的值变为0时,因await方法阻塞的线程会被唤醒,继续执行。
CountDownLatch不能再次使用(计数减到0后,就一直为0了)
CyclicBarrier线程栅拦 /ˈsɪklɪk/ /ˈbæriər/
CyclicBarrier是什么
栅栏屏障,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续运行。
CyclicBarrier默认的构造方法是CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用await方法告CyclicBarrier我已经到达了屏障,然后当前线程被阻塞。当所有线程到达屏障时,被await()阻塞的线程继续执行
有点像我们前面CountDownLatch的一种写法
CyclicBarrier默认的构造方法是CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用await方法告CyclicBarrier我已经到达了屏障,然后当前线程被阻塞。当所有线程到达屏障时,被await()阻塞的线程继续执行
有点像我们前面CountDownLatch的一种写法
//每个线程调用await方法告CyclicBarrier我已经到达了屏障,然后当前线程被阻塞。
cyclicBarrier.await();
CyclicBarrier还提供一个更高级的构造函数CyclicBarrier(int parties,Runnable barrierAction),用于在线程到达屏障时,优先执行barrierAction,方便处理更复杂的业务场景
cyclicBarrier.await();
CyclicBarrier还提供一个更高级的构造函数CyclicBarrier(int parties,Runnable barrierAction),用于在线程到达屏障时,优先执行barrierAction,方便处理更复杂的业务场景
案例:创建了new CyclicBarrier(int parties,Runnable barrierAction),需要调用parties次await();方法,当最后一次调用await();后,会首先执行barrierAction线程,可以看到就算我这里在barrierAction里面睡眠了2s,也是先执行完barrierAction线程才唤醒其他线程
CyclicBarrier和CountDownLatch的区别
CountDownLatch的计数器只能使用一次,而CyclicBarrier的计数器可以使用reset()方法重置。所以CyclicBarrier能处理更为复杂的业务场景。例如,如果计算发生错误,可以重置计数器,并让线程重新执行一次。
CyclicBarrier还提供其他有用的方法,比如getNumberWaiting方法可以获得Cyclic-Barrier阻塞的线程数量。isBroken()方法用来了解阻塞的线程是否被中断
Executors创建线程池工具
主要用来创建线程池,代理了线程池的创建,使得你的创建入口参数变得简单。
- 不过不推荐使用Executors创建线程池
重要方法:
- newCachedThreadPool创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。
- newFixedThreadPool 创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。
- newScheduledThreadPool 创建一个定长线程池,支持定时及周期性任务执行。
- newSingleThreadExecutor 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行
线程间交换数据的Exchanger
当一个线程运行到exchange()方法时会阻塞,另一个线程运行到exchange()时,二者交换数据,然后执行后面的程序。
- 应用场景:极少,大家了解即可
阻塞队列BlockingQueue详解
阻塞队列BlockingQueue
什么是阻塞队列BlockingQueue
BlockingQueue,是java.util.concurrent 包提供的用于解决并发生产者 - 消费者问题的最有用的类,它的特性是在任意时刻只有一个线程可以进行take/teɪk/(拿)或者put操作,并且BlockingQueue提供了超时return null的机制,在许多生产场景里都可以看到这个工具的身影
- 1)支持阻塞的插入方法:意思是当队列满时,队列会阻塞插入元素的线程,直到队列不满。
- 2)支持阻塞的移除方法:意思是在队列为空时,获取元素的线程会等待队列变为非空。
BlockingQueue接口的几个插入和移除操作处理方式
插入方法
add(e)
抛异常:如果插入成功则返回 true,否则抛出 IllegalStateException 异常
offer(e) /ˈɔːfər/
有返回值:当往队列插入元素时,会返回元素是否插入成功,成功返回true,满了返回false不抛异常。offer(e,time,unit)当阻塞队列满时,如果生产者线程往队列里插入元素,队列会阻塞生产者线程一段时间,如果超过了指定的时间,生产者线程就会退出,返回false
put(e)
一直阻塞:当阻塞队列满时,如果生产者线程往队列里put元素,队列会一直阻塞生产者线程,直到队列可用或者响应中断退出
移除方法
remove()
抛异常:当队列空时,从队列里获取元素会抛出NoSuchElementException异常
poll()
有返回值:从队列里取出一个元素,如果没有则返回null,poll(time,unit)如果队列为空,则阻塞一段时间后还是获取不到返回null
take() /teɪk/
一直阻塞:当队列空时,如果消费者线程从队列里take元素,队列会阻塞住消费者线程,直到队列不为空
检查方法
element()
检索,但不删除,这个队列的头。 此方法与peek的不同之处在于,如果此队列为空,它将抛出异常。
peek()
检索但不删除此队列的头部,如果此队列为空,则返回 null
注意:如果是无界阻塞队列,队列不可能会出现满的情况,所以使用put或offer方法永远不会被阻塞,而且使用offer方法时,该方法永远返回true
记忆方法:put和take分别尾首含有字母t(停)一直阻塞,offer和poll都含有字母o并且支持超时设置有返回值无异常
队列类型:无限队列、有限队列
无限队列 (unbounded queue ) - 几乎可以无限增长
有限队列 ( bounded queue ) - 定义了最大容量
队列数据结构
队列实质就是一种存储数据的结构
- 通常用链表或者数组实现
- 一般而言队列具备FIFO先进先出的特性,当然也有双端队列(Deque)优先级队列
- 主要操作:入队(EnQueue)与出队(Dequeue)
JDK 7提供了7个阻塞队列
- ArrayBlockingQueue:一个由数组结构组成的有界阻塞队列。
- LinkedBlockingQueue:一个由链表结构组成的有界阻塞队列。
- PriorityBlockingQueue:一个支持优先级排序的无界阻塞队列。
- DelayQueue:一个使用优先级队列实现的无界阻塞队列。
- SynchronousQueue:一个不存储元素的阻塞队列。
- LinkedTransferQueue:一个由链表结构组成的无界阻塞队列。
- LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。
几种队列的介绍与使用
ArrayBlockingQueue /əˈreɪ/
ArrayBlockingQueue是一个用数组实现的有界阻塞队列。容量大小在创建ArrayBlockingQueue对象时已定义好。此队列按照先进先出(FIFO)的原则对元素进行排序。
队列创建
//非公平性
BlockingQueue<String> blockingQueue = new ArrayBlockingQueue<>(10);
//公平性
ArrayBlockingQueue fairQueue = new ArrayBlockingQueue(1000,true);
BlockingQueue<String> blockingQueue = new ArrayBlockingQueue<>(10);
//公平性
ArrayBlockingQueue fairQueue = new ArrayBlockingQueue(1000,true);
默认情况下不保证线程公平的访问队列,所谓公平访问队列是指阻塞的线程,可以按照阻塞的先后顺序访问队列,即先阻塞线程先访问队列。非公平性是对先等待的线程是非公平的,当队列可用时,阻塞的线程都可以争夺访问队列的资格,有可能先阻塞的线程最后才访问队列。为了保证公平性,通常会降低吞吐量。
工作原理
基于ReentrantLock保证线程安全,根据Condition实现队列满时的阻塞
LinkedBlockingQueue
LinkedBlockingQueue是一个用链表实现的有界阻塞队列。此队列的默认和最大长度为Integer.MAX_VALUE。此队列按照先进先出的原则对元素进行排序。
//blockingQueue 的容量将设置为 Integer.MAX_VALUE 。
BlockingQueue<String> blockingQueue = new LinkedBlockingQueue<>();
BlockingQueue<String> blockingQueue = new LinkedBlockingQueue<>();
PriorityBlockingQueue /praɪˈɒrəti/
PriorityBlockingQueue是一个支持优先级的无界阻塞队列。默认情况下元素采取自然顺序升序排列。也可以自定义类实现compareTo()方法来指定元素排序规则,或者初始化PriorityBlockingQueue时,指定构造参数Comparator来对元素进行排序。需要注意的是不能保证同优先级元素的顺序
DelayQueue /dɪˈleɪ/
DelayQueue是一个支持延时获取元素的无界阻塞队列。队列使用PriorityQueue来实现(基于数组的扩容实现)。队列中的元素必须实现Delayed接口,在创建元素时可以指定多久才能从队列中获取该元素。只有在延迟期满时才能从队列中提取元素
队列创建
BlockingQueue<T> blockingQueue = new DelayQueue();
- 要求:入队的对象必须要实现Delayed接口,而Delayed集成自Comparable接口
应用场景
缓存系统的设计:可以用DelayQueue保存缓存元素的有效期,使用一个线程循环查询DelayQueue,一旦能从DelayQueue中获取元素时,表示缓存有效期到了。
定时任务调度:使用DelayQueue保存当天将会执行的任务和执行时间,一旦从DelayQueue中获取到任务就开始执行,比如TimerQueue就是使用DelayQueue实现的
定时任务调度:使用DelayQueue保存当天将会执行的任务和执行时间,一旦从DelayQueue中获取到任务就开始执行,比如TimerQueue就是使用DelayQueue实现的
工作原理
队列内部会根据时间优先级进行排序。延迟类线程池周期执行
ConcurrentLinkedQueue
基于链表形式的队列,通过compare and swap(简称CAS)协议的方式,来保证多线程情况下数据的安全,不加锁,主要使用了Java中的sun.misc.Unsafe类来实现;
使用无限 BlockingQueue 设计生产者 - 消费者模型时最重要的是 消费者应该能够像生产者向队列添加消息一样快地消费消息 。否则,内存可能会填满,然后就会得到一个 OutOfMemory 异常
并发编程之Atomic&Unsafe魔法类
原子操作
相关术语
缓存行:缓存的最小操作单位
比较并交换:CAS操作需要输入两个数值,一个旧值(期望操作前的值)和一个新值,在操作期间先比较下在旧值有没有发生变化,如果没有发生变化,才交换成新值,发生了变化则不交换。
CPU流水线:CPU流水线的工作方式就象工业生产上的装配流水线,在CPU中由5~6个不同功能的电路单元组成一条指令处理流水线,然后将一条X86指令分成5~6步后再由这些电路单元分别执行,这样就能实现在一个CPU时钟周期完成一条指令,因此提高CPU的运算速度。
内存顺序冲突:内存顺序冲突一般是由假共享引起,假共享是指多个CPU同时修改同一个缓存行的不同部分而引起其中一个CPU的操作无效,当出现这个内存顺序冲突时,CPU必须清空流水线。
处理器如何实现原子操作
处理器自动保证基本内存操作的原子性
首先处理器会自动保证基本的内存操作的原子性。处理器保证从系统内存当中读取或者写入一个字节是原子的,意思是当一个处理器读取一个字节时,其他处理器不能访问这个字节的内存地址。奔腾6和最新的处理器能自动保证单处理器对同一个缓存行里进行16/32/64位的操作是原子的,但是复杂的内存操作处理器不能自动保证其原子性,比如跨总线宽度,跨多个缓存行,跨页表的访问。但是处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性
使用总线锁保证原子性
第一个机制是通过总线锁保证原子性。如果多个处理器同时对共享变量进行读改写(i++就是经典的读改写操作)操作,那么共享变量就会被多个处理器同时进行操作,这样读改写操作就不是原子的,操作完之后共享变量的值会和期望的不一致,举个例子:如果i=1,我们进行两次i++操作,我们期望的结果是3,但是有可能结果是2
处理器使用总线锁就是来解决这个问题的。所谓总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占使用共享内存
使用缓存锁保证原子性
在同一时刻我们只需保证对某个内存地址的操作是原子性即可,但总线锁定把CPU和内存之间通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,最近的处理器在某些场合下使用缓存锁定代替总线锁定来进行优化,当其他处理器回写已被锁定的缓存行的数据时会起缓存行无效
两种情况下处理器不会使用缓存锁定:
- 第一种情况是:当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行(cache line),则处理器会调用总线锁定。
- 第二种情况是:有些处理器不支持缓存锁定。对于Inter486和奔腾处理器,就算锁定的内存区域在处理器的缓存行中也会调用总线锁定
Java当中如何实现原子操作
使用循环CAS实现原子操作
JVM中的CAS操作正是利用了处理器提供的CMPXCHG指令实现的。自旋CAS实现的基本思路就是循环进行CAS操作直到成功为止
从Java 1.5开始,JDK的并发包里提供了一些类来支持原子操作,如AtomicBoolean(用原子方式更新的boolean值)、AtomicInteger(用原子方式更新的int值)和AtomicLong(用原子方式更新的long值)。这些原子包装类还提供了有用的工具方法,比如以原子的方式将当前值自增1和自减1
使用锁机制实现原子操作
锁机制保证了只有获得锁的线程才能够操作锁定的内存区域。JVM内部实现了很多种锁机制,有偏向锁、轻量级锁和互斥锁。有意思的是除了重量级锁,JVM实现锁的方式都用了循环CAS,即当一个线程想进入同步块的时候使用循环CAS的方式来获取锁,当它退出同步块的时候使用循环CAS释放锁
CAS实现原子操作的三大问题
ABA问题
ABA问题。因为CAS需要在操作值的时候,检查值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。比如王百万要去银行存一百万进去,然后再去查钱存进去了没,但是如果在他存钱成功后到去查账这个中间,一个银行经理拿着他这一百万去买股票,结果还赚成150万,然后再把一百万放回王百万账户,那这个场景是不是就不对了,属于非法了
解决思路
ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加1,那么A→B→A就会变成1A→2B→3A。从Java 1.5开始,JDK的Atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值
循环时间长开销大
循环时间长开销大。自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令,那么效率会有一定的提升。pause指令有两个作用:
- 第一,它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零;
- 第二,它可以避免在退出循环的时候因内存顺序冲突(Memory Order Violation)而引起CPU流水线被清空(CPU Pipeline Flush),从而提高CPU的执行效率
只能保证一个共享变量的原子操作
只能保证一个共享变量的原子操作。当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁。还有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如,有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java 1.5开始,JDK提供了AtomicReference/ˈrefrəns/类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行CAS操作
Atomic
分类
基本数据类型:AtomicInteger、AtomicLong、AtomicBoolean
引用类型:AtomicReference、AtomicReference的ABA实例:AtomicStampedRerence、AtomicMarkableReference
数组类型:AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray
属性原子修改器(Updater):AtomicIntegerFieldUpdater、AtomicLongFieldUpdater、AtomicReferenceFieldUpdater
AtomicInteger常用方法
- int addAndGet(int delta) :以原子方式将输入的数值与实例中的值(AtomicInteger里的value)相加,并返回结果
- boolean compareAndSet(int expect, int update) :如果输入的数值等于预期值,则以原子方式将该值设置为输入的值。
- int getAndIncrement():以原子方式将当前值加1,注意:这里返回的是自增前的值。
- void lazySet(int newValue):最终会设置成newValue,使用lazySet设置值后,可能导致其他线程在之后的一小段时间内还是可以读到旧的值。
- int getAndSet(int newValue):以原子方式设置为newValue的值,并返回旧值
原子更新浮点型说明
Atomic包提供了三种基本类型的原子更新,但是Java的基本类型里还有char,float和double等。那么问题来了,如何原子的更新其他的基本类型呢?Atomic包里的类基本都是使用Unsafe实现的,Unsafe只提供了三种CAS方法,compareAndSwapObject,compareAndSwapInt和compareAndSwapLong,再看AtomicBoolean源码,发现其是先把Boolean转换成整型,再使用compareAndSwapInt进行CAS,所以原子更新double也可以用类似的思路来实现(拆分开,操作完再合并)
AtomicIntegerArray常用方法
- int addAndGet(int i, int delta):以原子方式将输入值与数组中索引i的元素相加。
- boolean compareAndSet(int i, int expect, int update):如果当前值等于预期值,则以原子方式将数组位置i的元素设置成update值
需要注意的是,数组value通过构造方法传递进去,然后AtomicIntegerArray会将当前数组复制一份,所以当AtomicIntegerArray对内部的数组元素进行修改时,不会影响传入的数组。
原子更新引用类型
- AtomicReference:原子更新引用类型。
- AtomicReferenceFieldUpdater:原子更新引用类型里的字段。
- AtomicMarkableReference:原子更新带有标记位的引用类型(解决ABA问题)。可以原子的更新一个布尔类型的标记位和引用类型。构造方法是AtomicMarkableReference(V initialRef, boolean initialMark)
原子更新字段类/属性原子修改器
如果我们只需要某个类里的某个字段,那么就需要使用原子更新字段类,Atomic包提供了以下三个类:
要想原子地更新字段类需要两步。第一步,因为原子更新字段类都是抽象类,每次使用的时候必须使用静态方法newUpdater()创建一个更新器,并且需要设置想要更新的类和属性。第二步,更新类的字段(属性)必须使用public volatile修饰符
- AtomicIntegerFieldUpdater:原子更新整型的字段的更新器。
- AtomicLongFieldUpdater:原子更新长整型字段的更新器。
- AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于原子的更新数据和数据的版本号,可以解决使用CAS进行原子更新时可能出现的ABA问题
要想原子地更新字段类需要两步。第一步,因为原子更新字段类都是抽象类,每次使用的时候必须使用静态方法newUpdater()创建一个更新器,并且需要设置想要更新的类和属性。第二步,更新类的字段(属性)必须使用public volatile修饰符
Unsafe /seɪf/ 应用解析
Unsafe说明
Unsafe是位于sun.misc包下的一个类,主要提供一些用于执行低级别、不安全操作的方法,如直接访问系统内存资源、自主管理内存资源等,这些方法在提升Java运行效率、增强Java语言底层资源操作能力方面起到了很大的作用。但由于Unsafe类使Java语言拥有了类似C语言指针一样操作内存空间的能力,这无疑也增加了程序发生相关指针问题的风险。在程序中过度、不正确使用Unsafe类会使得程序出错的概率变大,使得Java这种安全的语言变得不再“安全”,因此对Unsafe的使用一定要慎重。需要向C++一样自己释放资源,因为这样申请的资源并不会由JVM管理
Unsafe功能介绍
Unsafe提供的API大致可分为内存操作、CAS、Class相关、对象操作、线程调度、系统信息获取、内存屏障、数组操作等几类
ThreadPoolExecutor线程池
线程与线程池
线程与协程
线程是调度CPU资源的最小单位,线程模型分为KLT模型与ULT模型,JVM使用的KLT模型,Java线程与OS线程保持1:1的映射关系,也就是说有一个java线程也会在操作系统里有一个对应的线程。Java线程有多种生命状态
协程 (纤程,用户级线程),目的是为了追求最大力度的发挥硬件性能和提升软件的速度,协程基本原理是:在某个点挂起当前的任务,并且保存栈信息,去执行另一个任务;等完成或达到某个条件时,再还原原来的栈信息并继续执行(整个过程线程不需要上下文切换)
线程的实现方式
Runnable /ˈrʌnəbl/,Thread /θred/,Callable /ˈkɔːləbəl/
Callable同样是任务,与Runnable接口的区别在于它接收泛型,同时它执行任务后带有返回内容
// 相对于run方法的带有返回值的call方法
V call() throws Exception;
// 相对于run方法的带有返回值的call方法
V call() throws Exception;
线程池
“线程池”,顾名思义就是一个线程缓存,线程是稀缺资源,如果被无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,因此Java中提供线程池对线程进行统一分配、调优和监控
线程池的好处:
- 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度。当任务到达时,任务可以不需要的等到线程创建就能立即执行。
- 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
任务性质类型
CPU密集型(CPU-bound)
CPU密集型也叫计算密集型,指的是系统的硬盘、内存性能相对CPU要好很多,此时,系统运作大部分的状况是CPU Loading 100%,CPU要读/写I/O(硬盘/内存),I/O在很短的时间就可以完成,而CPU还有许多运算要处理,CPU Loading很高
线程数一般设置为: 线程数 = CPU核数+1 (现代CPU支持超线程,引入多组前端部件共享执行引擎)
IO密集型(I/O bound)
IO密集型指的是系统的CPU性能相对硬盘、内存要好很多,此时,系统运作,大部分的状况是CPU在等I/O (硬盘/内存) 的读/写操作,此时CPU Loading并不高
线程数一般设置为: 线程数 = ((线程等待时间+线程CPU时间)/线程CPU时间 )* CPU数目
CPU密集型 vs IO密集型
计算密集型任务的特点是要进行大量的计算,消耗CPU资源,这种计算密集型任务虽然也可以用多任务完成,但是任务越多,花在任务切换的时间就越多,CPU执行任务的效率就越低,要最高效地利用CPU,计算密集型任务同时进行的数量应当等于CPU的核心数
计算密集型任务由于主要消耗CPU资源,因此,代码运行效率至关重要。Python这样的脚本语言运行效率很低,完全不适合计算密集型任务。对于计算密集型任务,最好用C语言编写
计算密集型任务由于主要消耗CPU资源,因此,代码运行效率至关重要。Python这样的脚本语言运行效率很低,完全不适合计算密集型任务。对于计算密集型任务,最好用C语言编写
IO密集型,涉及到网络、磁盘IO的任务都是IO密集型任务,这类任务的特点是CPU消耗很少,任务的大部分时间都在等待IO操作完成(因为IO的速度远远低于CPU和内存的速度)。对于IO密集型任务,任务越多,CPU效率越高
IO密集型任务执行期间,99%的时间都花在IO上,花在CPU上的时间很少,因此,用运行速度极快的C语言替换用Python这样运行速度极低的脚本语言,完全无法提升运行效率。对于IO密集型任务,最合适的语言就是开发效率最高(代码量最少)的语言,脚本语言是首选,C语言最差
IO密集型任务执行期间,99%的时间都花在IO上,花在CPU上的时间很少,因此,用运行速度极快的C语言替换用Python这样运行速度极低的脚本语言,完全无法提升运行效率。对于IO密集型任务,最合适的语言就是开发效率最高(代码量最少)的语言,脚本语言是首选,C语言最差
Executor/ɪɡˈzekjətə(r)/框架
所有的线程池相关的类都是Executor下面的子类,将任务的执行与提交分离开,Executor只有一个抽象方法execute(Runnable command)
Executor框架的结构
Executor是一个接口,它是Executor框架的基础,它将任务的提交与任务的执行分离开来。
ThreadPoolExecutor是线程池的核心实现类,用来执行被提交的任务。
ScheduledThreadPoolExecutor是一个实现类,可以在给定的延迟后运行命令,或者定期执行命令。ScheduledThreadPoolExecutor比Timer更灵活,功能更强大。
Future接口和实现Future接口的FutureTask类,代表异步计算的结果。
Runnable接口和Callable接口的实现类,都可以被ThreadPoolExecutor或Scheduled-ThreadPoolExecutor执行
结构
任务:包括被执行任务需要实现的接口:Runnable接口或Callable接口
任务的执行:包括任务执行机制的核心接口Executor,以及继承自Executor的ExecutorService接口。Executor框架有两个关键类实现了ExecutorService接口(ThreadPoolExecutor和ScheduledThreadPoolExecutor)
异步计算的结果:包括接口Future和实现Future接口的FutureTask类
ExecutorService
- execute(Runnable command):履行Ruannable类型的任务,
- submit(task):可用来提交Callable或Runnable任务,并返回代表此任务的Future对象
- shutdown():在完成已提交的任务后封闭办事,不再接管新任务,
- shutdownNow():停止所有正在履行的任务并封闭办事。
- isTerminated():测试是否所有任务都履行完毕了。
- isShutdown():测试是否该ExecutorService已被关闭。
ThreadPoolExecutor
线程池的创建核心参数
corePoolSize:线程池中的核心线程数,当提交一个任务时,线程池创建一个新线程执行任务,直到当前线程数等于corePoolSize;如果当前线程数为corePoolSize,继续提交的任务被保存到阻塞队列中,等待被执行;如果执行了线程池的prestartAllCoreThreads()方法,线程池会提前创建并启动所有核心线程
maximumPoolSize:线程池中允许的最大线程数。如果当前阻塞队列满了,且继续提交任务,则创建新的线程执行任务,前提是当前线程数小于maximumPoolSize
keepAliveTime:线程池维护线程所允许的空闲时间。当线程池中的线程数量大于corePoolSize的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了keepAliveTime
unit:keepAliveTime的单位;可选的单位有天(DAYS)、小时(HOURS)、分钟(MINUTES)、毫秒(MILLISECONDS)、微秒(MICROSECONDS,千分之一毫秒)和纳秒(NANOSECONDS)
workQueue:用来保存等待被执行的任务的阻塞队列,且任务必须实现Runable接口,在JDK中提供了如下阻塞队列:
- ArrayBlockingQueue:基于数组结构的有界阻塞队列,按FIFO排序任务;
- LinkedBlockingQuene:基于链表结构的阻塞队列,按FIFO排序任务,吞吐量通常要高于ArrayBlockingQuene;
- SynchronousQuene:一个不存储元素的阻塞队列,每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于LinkedBlockingQuene;
- priorityBlockingQuene:具有优先级的无界阻塞队列;
threadFactory:它是ThreadFactory类型的变量,用来创建新线程。默认使用Executors.defaultThreadFactory() 来创建线程。使用默认的ThreadFactory来创建线程时,会使新创建的线程具有相同的NORM_PRIORITY优先级并且是非守护线程,同时也设置了线程的名称
handler:线程池的饱和策略,当阻塞队列满了,且没有空闲的工作线程,如果继续提交任务,必须采取一种策略处理该任务,线程池提供了4种策略:
上面的4种策略都是ThreadPoolExecutor的内部类。当然也可以根据应用场景实现RejectedExecutionHandler接口,自定义饱和策略,如记录日志或持久化存储不能处理的任务
- AbortPolicy:直接抛出异常,默认策略;
- CallerRunsPolicy:用调用者所在的线程来执行任务;
- DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务;
- DiscardPolicy:直接丢弃任务;
上面的4种策略都是ThreadPoolExecutor的内部类。当然也可以根据应用场景实现RejectedExecutionHandler接口,自定义饱和策略,如记录日志或持久化存储不能处理的任务
向线程池提交任务
public void execute() //提交任务无返回值,所以无法判断任务是否被线程池执行成功。
public Future<?> submit() //任务执行完成后有返回值,线程池会返回一个future类型的对象,通过这个future对象可以判断任务是否执行成功,并且可以通过future的get()方法来获取返回值,get()方法会阻塞当前线程直到任务完成,而使用get(long timeout,TimeUnit unit)方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完
关闭线程池
//在完成已提交的任务后封闭办事,不再接管新任务
public void shutdown()
public void shutdown()
//停止所有正在履行的任务并封闭办事。
public List<Runnable> shutdownNow()
public List<Runnable> shutdownNow()
可以通过调用线程池的shutdown或shutdownNow方法来关闭线程池。它们的原理是遍历线程池中的工作线程,然后逐个调用线程的interrupt方法来中断线程,所以无法响应中断的任务可能永远无法终止。但是它们存在一定的区别,shutdownNow首先将线程池的状态设置成STOP,然后尝试停止所有的正在执行或暂停任务的线程,并返回等待执行任务的列表,而shutdown只是将线程池的状态设置成SHUTDOWN状态,然后中断所有没有正在执行任务的线程
只要调用了这两个关闭方法中的任意一个,isShutdown方法就会返回true。当所有的任务都已关闭后,才表示线程池关闭成功,这时调用isTerminaed方法会返回true。至于应该调用哪一种方法来关闭线程池,应该由提交到线程池的任务特性决定,通常调用shutdown方法来关闭线程池,如果任务不一定要执行完,则可以调用shutdownNow方法
线程池监控
- public long getTaskCount() //线程池已执行与未执行的任务总数
- public long getCompletedTaskCount() //已完成的任务数
- public int getPoolSize() //线程池当前的线程数
- public int getActiveCount() //线程池中正在执行任务的线程数量
- public int getLargestPoolSize() //线程池里曾经创建过的最大线程数量。通过这个数据可以知道线程池是否曾经满过。该数值等于线程池的最大大小,则线程池曾经满过
protected void beforeExecute(Thread t, Runnable r) { }
protected void afterExecute(Runnable r, Throwable t) { }
protected void terminated() { }
通过扩展线程池进行监控。可以通过继承线程池来自定义线程池,重写线程池的beforeExecute、afterExecute和terminated方法,也可以在任务执行前、执行后和线程池关闭前执行一些代码来进行监控。例如,监控任务的平均执行时间、最大执行时间和最小执行时间等。
protected void afterExecute(Runnable r, Throwable t) { }
protected void terminated() { }
通过扩展线程池进行监控。可以通过继承线程池来自定义线程池,重写线程池的beforeExecute、afterExecute和terminated方法,也可以在任务执行前、执行后和线程池关闭前执行一些代码来进行监控。例如,监控任务的平均执行时间、最大执行时间和最小执行时间等。
合理地配置线程池
要想合理地配置线程池,就必须首先分析任务特性,可以从以下几个角度来分析。
- 任务的性质:CPU密集型任务、IO密集型任务和混合型任务。
- 任务的优先级:高、中和低。
- 任务的执行时间:长、中和短。
- 任务的依赖性:是否依赖其他系统资源,如数据库连接
性质不同的任务可以用不同规模的线程池分开处理。
- CPU密集型任务应配置尽可能小的线程,如配置N cpu +1个线程的线程池。
- 由于IO密集型任务线程并不是一直在执行任务,则应配置尽可能多的线程,如2*N cpu 。
- 混合型的任务,如果可以拆分,将其拆分成一个CPU密集型任务和一个IO密集型任务
- 优先级不同的任务可以使用优先级队列PriorityBlockingQueue来处理。它可以让优先级高的任务先执行。(注意:如果一直有优先级高的任务提交到队列里,那么优先级低的任务可能永远不能执行。)
- 执行时间不同的任务可以交给不同规模的线程池来处理,或者可以使用优先级队列,让执行时间短的任务先执行
- 依赖数据库连接池的任务,因为线程提交SQL后需要等待数据库返回结果,等待的时间越长,则CPU空闲时间就越长,那么线程数应该设置得越大,这样才能更好地利用CPU
建议使用有界队列。有界队列能增加系统的稳定性和预警能力,可以根据需要设大一点儿,比如几千。有一次,我们系统里后台任务线程池的队列和线程池全满了,不断抛出抛弃任务的异常,通过排查发现是数据库出现了问题,导致执行SQL变得非常缓慢,因为后台任务线程池里的任务全是需要向数据库查询和插入数据的,所以导致线程池里的工作线程全部阻塞,任务积压在线程池里。如果当时我们设置成无界队列,那么线程池的队列就会越来越多,有可能会撑满内存,导致整个系统不可用,而不只是后台任务出现问题。当然,我们的系统所有的任务是用单独的服务器部署的,我们使用不同规模的线程池完成不同类型的任务,但是出现这样问题时也会影响到其他任务
ThreadPoolExecutor线程池原理
线程池重点属性--ctl
ctl饰演者什么角色
ctl 是对线程池的运行状态和线程池中有效线程的数量进行控制的一个字段, 它包含两部分的信息: 线程池的运行状态 (runState) 和线程池内有效线程的数量 (workerCount),这里可以看到,使用了Integer类型来保存,高3位保存runState,低29位保存workerCount。COUNT_BITS 就是29,CAPACITY就是1左移29位减1(29个1),这个常量表示workerCount的上限值,大约是5亿
ctl相关方法
private static int runStateOf(int c) { return c & ~CAPACITY; }//获取运行状态;
private static int workerCountOf(int c) { return c & CAPACITY; }//获取活动线程数;
private static int ctlOf(int rs, int wc) { return rs | wc; }//获取运行状态和活动线程数的值。
ctl表示runState-线程池存在5种状态
RUNNING
(1) 状态说明:线程池处在RUNNING状态时,能够接收新任务,以及对已添加的任务进行处理。
(2) 状态切换:线程池的初始化状态是RUNNING。换句话说,线程池被一旦被创建,就处于RUNNING状态,并且线程池中的任务数为0!
(1) 状态说明:线程池处在RUNNING状态时,能够接收新任务,以及对已添加的任务进行处理。
(2) 状态切换:线程池的初始化状态是RUNNING。换句话说,线程池被一旦被创建,就处于RUNNING状态,并且线程池中的任务数为0!
SHUTDOWN
(1) 状态说明:线程池处在SHUTDOWN状态时,不接收新任务,但能处理已添加的任务。
(2) 状态切换:调用线程池的shutdown()接口时,线程池由RUNNING -> SHUTDOWN
(1) 状态说明:线程池处在SHUTDOWN状态时,不接收新任务,但能处理已添加的任务。
(2) 状态切换:调用线程池的shutdown()接口时,线程池由RUNNING -> SHUTDOWN
STOP
(1) 状态说明:线程池处在STOP状态时,不接收新任务,不处理已添加的任务,并且会中断正在处理的任务。
(2) 状态切换:调用线程池的shutdownNow()接口时,线程池由(RUNNING or SHUTDOWN ) -> STOP
(1) 状态说明:线程池处在STOP状态时,不接收新任务,不处理已添加的任务,并且会中断正在处理的任务。
(2) 状态切换:调用线程池的shutdownNow()接口时,线程池由(RUNNING or SHUTDOWN ) -> STOP
TIDYING /ˈtaɪdiɪŋ/收拾
(1) 状态说明:当所有的任务已终止,ctl记录的”任务数量”为0,线程池会变为TIDYING状态。当线程池变为TIDYING状态时,会执行钩子函数terminated()终止。terminated()在ThreadPoolExecutor类中是空的,若用户想在线程池变为TIDYING时,进行相应的处理;可以通过重写terminated()函数来实现。
(2) 状态切换:当线程池在SHUTDOWN状态下,阻塞队列为空并且线程池中执行的任务也为空时,就会由 SHUTDOWN -> TIDYING。 当线程池在STOP状态下,线程池中执行的任务为空时,就会由STOP -> TIDYING
(1) 状态说明:当所有的任务已终止,ctl记录的”任务数量”为0,线程池会变为TIDYING状态。当线程池变为TIDYING状态时,会执行钩子函数terminated()终止。terminated()在ThreadPoolExecutor类中是空的,若用户想在线程池变为TIDYING时,进行相应的处理;可以通过重写terminated()函数来实现。
(2) 状态切换:当线程池在SHUTDOWN状态下,阻塞队列为空并且线程池中执行的任务也为空时,就会由 SHUTDOWN -> TIDYING。 当线程池在STOP状态下,线程池中执行的任务为空时,就会由STOP -> TIDYING
TERMINATED 终止
(1) 状态说明:线程池彻底终止,就变成TERMINATED状态。
(2) 状态切换:线程池处在TIDYING状态时,执行完terminated()之后,就会由 TIDYING -> TERMINATED。
进入TERMINATED的条件如下:
(1) 状态说明:线程池彻底终止,就变成TERMINATED状态。
(2) 状态切换:线程池处在TIDYING状态时,执行完terminated()之后,就会由 TIDYING -> TERMINATED。
进入TERMINATED的条件如下:
- 线程池不是RUNNING状态;
- 线程池状态不是TIDYING状态或TERMINATED状态;
- 如果线程池状态是SHUTDOWN并且workerQueue为空;
- workerCount为0;
- 设置TIDYING状态成功
线程池工作原理
ThreadPoolExecutor采取上述步骤的总体设计思路,是为了在执行execute()方法时,尽可能地避免获取全局锁(那将会是一个严重的可伸缩瓶颈,在队列排队而不需要一起强锁)。在ThreadPoolExecutor完成预热之后(当前运行的线程数大于等于corePoolSize),几乎所有的execute()方法调用都是执行步骤2,而步骤2不需要获取全局锁。
线程池中的线程执行任务分两种情况,如下:
1)在execute()方法中创建一个线程时,会让这个线程执行当前任务。
2)这个线程执行完上图中1的任务后,会反复从BlockingQueue获取任务来执行
1)在execute()方法中创建一个线程时,会让这个线程执行当前任务。
2)这个线程执行完上图中1的任务后,会反复从BlockingQueue获取任务来执行
源码讲解
execute方法提交任务的时候,会根据线程池数量是否创建worker,线程池中的每一个线程被封装成一个Worker对象,ThreadPool维护的其实就是一组Worker对象,Worker类继承了AQS,并实现了Runnable接口,注意其中的firstTask和thread属性:firstTask用它来保存传入的任务,thread是在调用构造方法时通过ThreadFactory来创建的线程(创建的时候其实就是new Thread并且设置Thread中的target为worker自己【Runnable不就是这么用的】),是用来处理任务的线程。在Worker的run方法中去执行了我们提交线程的run方法(注意这里并不会启动线程start)
ScheduledThreadPoolExecutor
ScheduledThreadPoolExecutor继承自ThreadPoolExecutor。它主要用来在给定的延迟之后运行任务,或者定期执行任务。ScheduledThreadPoolExecutor的功能与Timer类似,但ScheduledThreadPoolExecutor功能更强大、更灵活。Timer对应的是单个后台线程,而ScheduledThreadPoolExecutor可以在构造函数中指定多个对应的后台线程数
它采用DelayQueue存储等待的任务
DelayQueue内部封装了一个PriorityQueue,它会根据time的先后时间排序,若time相同则根据sequenceNumber排序(入队顺序);
DelayQueue也是一个无界队列
它采用DelayQueue存储等待的任务
DelayQueue内部封装了一个PriorityQueue,它会根据time的先后时间排序,若time相同则根据sequenceNumber排序(入队顺序);
DelayQueue也是一个无界队列
三种提交任务的方式
//达到给定的延时时间后,执行任务。这里传入的是实现Runnable接口的任务,因此通过ScheduledFuture.get()获取结果为null
public ScheduledFuture<?> schedule(Runnable command,long delay, TimeUnit unit);
//达到给定的延时时间后,执行任务。这里传入的是实现Callable接口的任务,因此,返回的是任务的最终计算结果
public <V> ScheduledFuture<V> schedule(Callable<V> callable,long delay, TimeUnit unit);
public ScheduledFuture<?> schedule(Runnable command,long delay, TimeUnit unit);
//达到给定的延时时间后,执行任务。这里传入的是实现Callable接口的任务,因此,返回的是任务的最终计算结果
public <V> ScheduledFuture<V> schedule(Callable<V> callable,long delay, TimeUnit unit);
//是以上一个任务开始的时间计时,period时间过去后,尝试执行,周期性执行任务
//检测上一个任务是否执行完毕,如果上一个任务执行完毕,则当前任务立即执行,如果上一个任务没有执行完毕,则需要等上一个任务执行完毕后立即执行
//任务在initialDelay后开始执行,每period执行一次(等到上个任务执行完开始执行,period执行只是尝试去执行,当上一个执行完了立马执行)
public ScheduledFuture<?> scheduleAtFixedRate(Runnable command,long initialDelay,long period,TimeUnit unit);
//检测上一个任务是否执行完毕,如果上一个任务执行完毕,则当前任务立即执行,如果上一个任务没有执行完毕,则需要等上一个任务执行完毕后立即执行
//任务在initialDelay后开始执行,每period执行一次(等到上个任务执行完开始执行,period执行只是尝试去执行,当上一个执行完了立马执行)
public ScheduledFuture<?> scheduleAtFixedRate(Runnable command,long initialDelay,long period,TimeUnit unit);
//当达到延时时间initialDelay后,任务开始执行。上一个任务执行结束后到下一次任务执行,中间延时时间间隔为delay。以这种方式,周期性执行任务。
//任务在initialDelay后开始执行,每delay执行一次(它是在任务执行完后再过delay再执行)
public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command,long initialDelay,long delay,TimeUnit unit);
//任务在initialDelay后开始执行,每delay执行一次(它是在任务执行完后再过delay再执行)
public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command,long initialDelay,long delay,TimeUnit unit);
工作线程的执行过程
- 工作线程会从DelayQueue取已经到期的任务去执行;
- 如果是周期任务的话执行结束后重新设置任务的到期时间,再次放回DelayQueue
任务排序方法
- 首先按照time排序,time小的排在前面,time大的排在后面;
- 如果time相同,按照sequenceNumber排序,sequenceNumber小的排在前面,sequenceNumber大的排在后面,换句话说,如果两个task的执行时间相同,优先执行先提交的task。
ForkJoin框架与无锁并发框架-Disruptor
Fork/Join 框架
什么是 Fork/Join 框架?
Fork/Join 框架是 Java7 提供了的一个用于并行执行任务的框架, 是一个把大任务分割成若干个小任务,最终汇总每个小任务结果后得到大任务结果的框架
Fork 就是把一个大任务切分为若干子任务并行的执行,Join 就是合并这些子任务的执行结果,最后得到这个大任务的结果
工作窃取算法
工作窃取(work-stealing)算法是指某个线程从其他队列里窃取任务来执行。
把这个任务分割为若干互不依赖的子任务,为了减少线程间的竞争,于是把这些子任务分别放到不同的队列里,并为每个队列创建一个单独的线程来执行队列里的任务,线程和队列一一对应。若某个线程执行完自己的队列了,会帮助别的线程执行:被窃取任务线程永远从双端队列的尾部拿任务执行,而窃取任务的线程永远从双端队列的头部拿任务执行
无锁并发框架-Disruptor/dɪsˈrʌptər/
认识Disruptor
Disruptor【美 /dɪsˈrʌptər/ 】是一个开源框架,研发的初衷是为了解决高并发下列队锁的问题,最早由LMAX(一种新型零售金融交易平台)提出并使用,能够在无锁的情况下实现队列的并发操作,并号称能够在一个线程里每秒处理6百万笔订单(这个真假就不清楚了!牛皮谁都会吹)
核心设计原理
Disruptor通过以下设计来解决队列速度慢的问题:
- 环形数组结构:为了避免垃圾回收,采用数组而非链表。同时,数组对处理器的缓存机制更加友好(回顾一下:CPU加载空间局部性原则)。
- 元素位置定位:数组长度2^n,通过位运算,加快定位的速度。下标采取递增的形式。不用担心index溢出的问题。index是long类型,即使100万QPS的处理速度,也需要30万年才能用完。
- 无锁设计:每个生产者或者消费者线程,会先申请可以操作的元素在数组中的位置,申请到之后,直接在该位置写入或者读取数据。
数据结构
每次存一个数据Sequence/ˈsiːkwəns/加一,然后取数据的时候在根据这个Sequence用位运算取模数组长度定位到元素真正索引。数组构建成了一个逻辑环,当元素中已经存在数据的时候,生产者会阻塞,会等到消费者消费后再生产
Disruptor与BlockingQueue区别
Disruptor大部分的并发代码都是通过对Sequence的值同步修改实现的,而非锁,同步修改到后就不占用资源了,这段Sequence就是该线程独占的,这是disruptor高性能的一个主要原因。
而BlockingQueue在消息入队的时候需要一直占用资源,只有入队结束后才会释放,其他线程才可以操作队列,还有它还是入队一次占用一次ReentrantLock锁,而Disruptor则是每个线程一次一个一个入队多个消息
而BlockingQueue在消息入队的时候需要一直占用资源,只有入队结束后才会释放,其他线程才可以操作队列,还有它还是入队一次占用一次ReentrantLock锁,而Disruptor则是每个线程一次一个一个入队多个消息
问题整理
什么是线程和进程?
何为进程?
进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。
在 Java 中,当我们启动 main 函数时其实就是启动了一个 JVM 的进程,而 main 函数所在的线程就是这个进程中的一个线程,也称主线程
在 Java 中,当我们启动 main 函数时其实就是启动了一个 JVM 的进程,而 main 函数所在的线程就是这个进程中的一个线程,也称主线程
何为线程?
线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程
请简要描述线程与进程的关系,区别及优缺点?
从JVM的角度来说:一个进程中可以有多个线程,多个线程共享进程的堆和方法区 (JDK1.8 之后的元空间)资源,但是每个线程有自己的程序计数器、虚拟机栈和本地方法栈
总结: 线程 是 进程 划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护;而进程正相反
程序计数器为什么是私有的?
程序计数器主要有下面两个作用:
所以,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置。
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
所以,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置。
虚拟机栈和本地方法栈为什么是私有的?
- 虚拟机栈: 每个 Java 方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
- 本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
一句话简单了解堆和方法区
堆和方法区是所有线程共享的资源,其中堆是进程中最大的一块内存,主要用于存放新创建的对象 (所有对象都在这里分配内存),方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
说说并发与并行的区别?
并发: 同一时间段,多个任务都在执行 (单位时间内不一定同时执行);逻辑上(眼睛)上同时执行
并行: 单位时间内,多个任务同时执行。物理上同时执行
并发是逻辑上的同时发生(simultaneous),而并行是物理上的同时发生
并行: 单位时间内,多个任务同时执行。物理上同时执行
并发是逻辑上的同时发生(simultaneous),而并行是物理上的同时发生
为什么要使用多线程呢?
先从总体上来说:
再深入到计算机底层来探讨:
- 从计算机底层来说: 线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。另外,多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销。
- 从当代互联网发展趋势来说: 现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。
再深入到计算机底层来探讨:
- 单核时代: 在单核时代多线程主要是为了提高 CPU 和 IO 设备的综合利用率。举个例子:当只有一个线程的时候会导致 CPU 计算时,IO 设备空闲;进行 IO 操作时,CPU 空闲。我们可以简单地说这两者的利用率目前都是 50%左右。但是当有两个线程的时候就不一样了,当一个线程执行 CPU 计算时,另外一个线程可以进行 IO 操作,这样两个的利用率就可以在理想情况下达到 100%了。
- 多核时代: 多核时代多线程主要是为了提高 CPU 利用率。举个例子:假如我们要计算一个复杂的任务,我们只用一个线程的话,CPU 只会一个 CPU 核心被利用到,而创建多个线程就可以让多个 CPU 核心被利用到,这样就提高了 CPU 的利用率。
使用多线程可能带来什么问题?
并发编程的目的就是为了能提高程序的执行效率提高程序运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏、上下文切换、死锁。原子性、可见性这些问题
说说线程的生命周期和状态?
线程的状态
初始(NEW):新创建了一个线程对象,但还没有调用start()方法
运行(RUNNABLE):Java线程将操作系统中的就绪(ready)和运行中(running)两种状态笼统的称为“运行”。
- 线程对象创建后,其他线程(比如main线程)调用了该对象的start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取CPU的使用权,此时处于就绪状态(ready)。就绪状态的线程在获得CPU时间片后变为运行中状态(running)
阻塞(BLOCKED):表示线程阻塞于锁。
等待(WAITING):进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。
超时等待(TIMED_WAITING):该状态不同于WAITING,它可以在指定的时间后自行返回
终止(TERMINATED):表示该线程已经执行完毕
线程的各种状态之间的转换图
注意:Java将操作系统中的运行和就绪两个状态合并称为运行状态。阻塞状态是线程阻塞在进入synchronized关键字修饰的方法或代码块时的状态,但是阻塞在java.concurrent包中Lock接口的线程状态却是等待状态,因为java.concurrent包中Lock接口对于阻塞的实现均使用了LockSupport类中的相关方法
当线程执行 wait()方法之后,线程进入 WAITING(等待) 状态。进入等待状态的线程需要依靠其他线程的通知或中断才能够返回到运行状态,而 TIME_WAITING(超时等待) 状态相当于在等待状态的基础上增加了超时限制,比如通过 sleep(long millis)方法或 wait(long millis)方法可以将 Java 线程置于 TIMED WAITING 状态。当超时时间到达后 Java 线程将会返回到 RUNNABLE 状态。当线程调用同步方法时,在没有获取到锁的情况下,线程将会进入到 BLOCKED(阻塞) 状态。线程在执行 Runnable 的run()方法之后将会进入到 TERMINATED(终止) 状态。
sleep、wait都不占用CPU
什么是上下文切换?
多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。
概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。
上下文切换通常是计算密集型的。也就是说,对计算密集型来说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作
Linux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。
什么是线程死锁?如何避免死锁?
认识线程死锁
线程死锁描述的是这样一种情况:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
比如,线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。
比如,线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。
产生死锁必须具备以下四个条件
- 互斥条件:该资源任意一个时刻只由一个线程占用。
- 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:线程已获得的资源在末使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
- 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
如何避免线程死锁?
我上面说了产生死锁的四个必要条件,为了避免死锁,我们只要破坏产生死锁的四个条件中的其中一个就可以了。现在我们来挨个分析一下:
- 破坏互斥条件 :这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问)。
- 破坏请求与保持条件 :一次性申请所有的资源。
- 破坏不剥夺条件 :占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
- 破坏循环等待条件 :靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。(两个线程都按照同样的获取锁资源顺序,这样B线程没有获取到第一个资源也就不会获取并占用第二个资源)
说说 sleep() 方法和 wait() 方法区别和共同点?
两者最主要的区别在于:sleep() 方法没有释放锁,而 wait() 方法释放了锁 。但都不会占用cpu,两者都可以暂停线程的执行。
wait() 通常被用于线程间交互/通信,sleep()通常被用于暂停执行。
wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify()或者 notifyAll() 方法。sleep()方法执行完成后,线程会自动苏醒。或者可以使用 wait(long timeout) 超时后线程会自动苏醒。
wait() 通常被用于线程间交互/通信,sleep()通常被用于暂停执行。
wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify()或者 notifyAll() 方法。sleep()方法执行完成后,线程会自动苏醒。或者可以使用 wait(long timeout) 超时后线程会自动苏醒。
为什么我们调用 start() 方法时会执行 run() 方法,为什么我们不能直接调用 run() 方法?
new 一个 Thread,线程进入了新建状态。调用 start()方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。 但是,直接执行 run() 方法,会把 run() 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。
总结: 调用 start() 方法方可启动线程并使线程进入就绪状态,直接执行 run() 方法的话不会以多线程的方式执行
总结: 调用 start() 方法方可启动线程并使线程进入就绪状态,直接执行 run() 方法的话不会以多线程的方式执行
说一说自己对于 synchronized 关键字的了解
先介绍synchronized的用处:jvm内置锁,synchronized 关键字解决的是多个线程之间访问资源的同步性,synchronized关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。在Java早期版本中,synchronized属于重量级锁,效率低下,在1.5之后版本做了重大的优化
再介绍版本优化;JDK1.6 对锁的实现引入了大量的优化,如适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
- 这个时候可能会问锁膨胀过程,或者这里也可以提一嘴concurrentHashMap在1.7用Lock,1.8后用synchronize,说明确实优化很好
也可以说下为什么重量级锁慢呢?
因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高
不管什么锁都是串行化线程,重量级锁之所以慢是因为需要调用操作系统级别(内核态)的指令,而且会挂起阻塞的线程,来回切换。偏向锁、轻量锁并不会,大部分场景都不会升级重量锁,在偏向锁跟轻量锁基本都能满足了
不管什么锁都是串行化线程,重量级锁之所以慢是因为需要调用操作系统级别(内核态)的指令,而且会挂起阻塞的线程,来回切换。偏向锁、轻量锁并不会,大部分场景都不会升级重量锁,在偏向锁跟轻量锁基本都能满足了
说说自己是怎么使用 synchronized 关键字
先说下三种使用方式
同步实例方法,锁是当前实例对象
- public synchronized void method()
同步类方法,锁是当前类对象
- public static synchronized void method()
同步代码块,锁是括号里面的对象
- synchronized (obj)
再说下使用注意:根据场景选择上面不同的使用方式,切记不可使用string当作锁对象,因为JVM中,字符串常量池具有缓存功能
可能会被问到双重校验锁实现对象单例DCL
创建对象的时候会分为三个指令:1-new(堆中开辟空间)、2-invokespecial(初始化对象)、3-astore_1(将instance指向堆中的地址),java存在指令重排优化,因为不可见性,所以可能存在线程B拿到线程A的没有初始化的空对象(1-new(堆中开辟空间)、3-astore_1(将instance指向堆中的地址)、2-invokespecial(初始化对象))
讲一下 synchronized 关键字的底层原理
首先说下javap编译会加上指令:synchronized同步语句块的实现使用的是monitorenter/ˈentər/和monitorexit/ˈeksɪt/指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置,当执行monitorenter指令时,线程试图获取锁也就是获取对象监视器monitor的持有权(也可以提下:其实有的并不是加这两个指令,但原理都是依赖操作系统的mutex lock)
然后可以根据对象头中的mark word进一步回答:偏向锁通过cas更改对象头中的偏向id,轻量级锁通过cas更改对象头的指针(指向线程的锁记录对象地址),重量级锁通过monitor监视器锁实现,对象头的指针指向的是c++中的ObjectMonitor(monitor监视器锁)地址
最后总结:synchronized同步语句块的实现使用的是monitorenter和monitorexit指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置。
synchronized修饰的方法并没有monitorenter指令和monitorexit指令,取得代之的确实是ACC_SYNCHRONIZED标识,该标识指明了该方法是一个同步方法。
不过两者的本质都是对对象监视器monitor的获取
synchronized修饰的方法并没有monitorenter指令和monitorexit指令,取得代之的确实是ACC_SYNCHRONIZED标识,该标识指明了该方法是一个同步方法。
不过两者的本质都是对对象监视器monitor的获取
构造方法可以使用 synchronized 关键字修饰么?
先说结论:构造方法不能使用 synchronized 关键字修饰。
构造方法本身就属于线程安全的(因为你new的时候每次都是创建新对象,何来并发可言),不存在同步的构造方法一说
构造方法本身就属于线程安全的(因为你new的时候每次都是创建新对象,何来并发可言),不存在同步的构造方法一说
synchronized 修饰方法的的情况
synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用
synchronized锁膨胀过程
先整体说下:锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。从JDK 1.6 中默认是开启偏向锁和轻量级锁的,可以通过-XX:-UseBiasedLocking来禁用偏向锁
当线程访问同步代码块的时候,会去检查mark work的锁状态,
偏向锁:如果是偏向锁,那就比较下mark word中记录的是否为当前线程id,是的话就执行,不是的话就尝试通过CAS操作去获取偏向锁,如果获取偏向锁失败说明需要升级为轻量级锁了,等待原持有锁的线程到达安全点后,如果原持有锁的线程已经退出同步代码块或未活动状态(如等待),则并不会升级锁而是重新尝试获取偏向锁,如果原持有锁的线程未退出同步块,则升级为轻量级锁,并且会让原持有锁的线程拿到轻量级锁,唤醒后继续执行;
轻量级锁:分配锁记录对象并将mark word拷贝到锁记录对象中去(栈上分配),尝试把对象头的mark word指向该锁记录对象(当前线程的锁记录对象),指向失败的话就开始自旋。如果自旋失败一定次数后还没分配到锁就需要升级为重量级锁了。
重量级锁:mark word中的monitor指针指向ObjectMonitor对象,通过操作系统的Mutex lock(互斥锁)实现锁机制,它是一个重量级锁,性能较低,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。
偏向锁:如果是偏向锁,那就比较下mark word中记录的是否为当前线程id,是的话就执行,不是的话就尝试通过CAS操作去获取偏向锁,如果获取偏向锁失败说明需要升级为轻量级锁了,等待原持有锁的线程到达安全点后,如果原持有锁的线程已经退出同步代码块或未活动状态(如等待),则并不会升级锁而是重新尝试获取偏向锁,如果原持有锁的线程未退出同步块,则升级为轻量级锁,并且会让原持有锁的线程拿到轻量级锁,唤醒后继续执行;
轻量级锁:分配锁记录对象并将mark word拷贝到锁记录对象中去(栈上分配),尝试把对象头的mark word指向该锁记录对象(当前线程的锁记录对象),指向失败的话就开始自旋。如果自旋失败一定次数后还没分配到锁就需要升级为重量级锁了。
重量级锁:mark word中的monitor指针指向ObjectMonitor对象,通过操作系统的Mutex lock(互斥锁)实现锁机制,它是一个重量级锁,性能较低,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。
- 不管什么锁都是串行化线程,重量级锁之所以慢是因为需要调用操作系统级别(内核态)的指令,而且会挂起阻塞的线程,来回切换。偏向锁、轻量锁并不会,大部分场景都不会升级重量锁,在偏向锁跟轻量锁基本都能满足了
为什么要弄一个 CPU 高速缓存呢?
先简单说:一切为了压榨cpu,因为io的速度比cpu处理速度发展慢太多,解决IO速度和CPU运算速度之间的不匹配问题
总结:CPU Cache 缓存的是内存数据用于解决 CPU 处理速度和内存不匹配的问题,内存缓存的是硬盘数据用于解决硬盘访问速度过慢的问题
讲一下 JMM(Java 内存模型)
java内存模型为一个抽象的概念,它描述着工作内存跟主内存之间的交互方式:线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量
为什么需要JMM(Java内存模型)
在 JDK1.2 之前,Java 的内存模型实现总是从主存(即共享内存)读取变量,是不需要进行特别的注意的。而在当前的 Java 内存模型下,线程可以把变量保存本地内存(比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。
在早期的c、c++语言中是没有定义内存模型的,其行为依赖于处理器本身的内存一致性模型,但不同的处理器处理结果可能会不一样,Java设计之初就引入了线程的概念,以充分利用现代处理器的计算能力,这既带来了强大、灵活的多线程机制,也带来了线程安全等令人混淆的问题,为了解决这个问题,Java内存模型(Java Memory Model,JMM)为我们提供了一个在纷乱之中达成一致的指导准则。规定了什么样的执行是符合规范的
java内存模型围绕着在并发过程中如何处理可见性、原子性、有序性这三个特性而建立的模型。
java内存模型围绕着在并发过程中如何处理可见性、原子性、有序性这三个特性而建立的模型。
说说 synchronized 关键字和 volatile 关键字的区别
synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在!
- volatile 关键字是线程同步的轻量级实现,所以**volatile性能肯定比synchronized关键字要好。但是volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块**。
- volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。
- volatile关键字主要用于解决变量在多个线程之间的可见性跟防止指令重排,而 synchronized 关键字解决的是多个线程之间访问资源的同步性
这里可以提一下synchronize没有根本解决可见性的事情显得跟别人不同:synchronize只是串行化了而已,简单说就是只有在同步逻辑里可以保证可见性
ThreadLocal /ˈloʊkl了解么?
通常情况下,我们创建的变量是可以被任何一个线程访问并修改的。如果想实现每一个线程都有自己的专属本地变量该如何解决呢? JDK 中提供的ThreadLocal类正是为了解决这样的问题。 ThreadLocal类主要解决的就是让每个线程绑定自己的值,可以将ThreadLocal类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据。
如果你创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是ThreadLocal变量名的由来。他们可以使用 get() 和 set() 方法来获取默认值或将其值更改为当前线程所存的副本的值,从而避免了线程资源竞争问题。
如果你创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是ThreadLocal变量名的由来。他们可以使用 get() 和 set() 方法来获取默认值或将其值更改为当前线程所存的副本的值,从而避免了线程资源竞争问题。
ThreadLocal 原理讲一下
Thread类中有一个ThreadLocalMap类型的变量threadLocals,默认情况下这两个变量都是null,只有当前线程调用ThreadLocal类的set或get方法时才创建它们,实际上我们创建一个公共的ThreadLocal变量并调用这两个方法的时候,会先拿到当前线程的threadLocals,然后调用的是该threadLocals也就是ThreadLocalMap对应的get()、set()方法,所以每个线程都能get到它自己的值。set的时候map的key为我们的threadlocal对象(弱引用形式),value为设置我们的值
ThreadLocal 内存泄露问题了解不?
ThreadLocalMap 中使用的 key 的值为 我们创建的ThreadLocal 弱引用(WeakReference里面存在一个引用指向了我们创建的ThreadLocal对象),而 value 是强引用。所以,如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。这样一来,ThreadLocalMap 中就会出现 key 为 null 的 Entry)(只有当前线程结束后随着ThreadLocalMap释放才会被回收)。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。ThreadLocalMap 实现中已经考虑了这种情况,在调用 set()、get()、remove() 方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal方法后 最好手动调用remove()方法
为什么需要ThreadLocal弱引用作为key?
假如使用强引用作为ThreadLocalMap里面的key存在什么问题呢,比如说我们创建了一个ThreadLocal变量,并设置了值,此时我们ThreadLocal对象存在两个引用指向它(我们自己的TIME_THREADLOCAL 跟ThreadLocalMap的Entry对象里面的引用),如果我们将TIME_THREADLOCAL=null,此时这个ThreadLocal对象因为存在引用无法被回收,只有当前线程结束后随着ThreadLocalMap释放才会被回收
为什么要用线程池?
池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率,便于管理线程:
- 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度。当任务到达时,任务可以不需要的等到线程创建就能立即执行。
- 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控
实现 Runnable 接口和 Callable 接口的区别
Runnable 接口不会返回结果或抛出检查异常,但是Callable 接口可以。所以,如果任务不需要返回结果或抛出异常推荐使用Runnable 接口,这样代码看起来会更加简洁
执行 execute()方法和 submit()方法的区别是什么呢?
execute()方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否;
submit()方法用于提交需要返回值的任务。线程池会返回一个 Future 类型的对象,通过这个 Future 对象可以判断任务是否执行成功,并且可以通过 Future 的 get()方法来获取返回值,get()方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit)方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完
submit()方法用于提交需要返回值的任务。线程池会返回一个 Future 类型的对象,通过这个 Future 对象可以判断任务是否执行成功,并且可以通过 Future 的 get()方法来获取返回值,get()方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit)方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完
如何创建线程池
《阿里巴巴 Java 开发手册》中强制线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险
方式一:Executors来创建线程池(不推荐,因为无法更加明确的规定线程池)
方式二:使用构造函数创建,比如ThreadPoolExecutor、ScheduleThreadPoolExecutor
方式二:使用构造函数创建,比如ThreadPoolExecutor、ScheduleThreadPoolExecutor
ThreadPoolExecutor 类分析
线程池的创建核心参数
corePoolSize:线程池中的核心线程数,当提交一个任务时,线程池创建一个新线程执行任务,直到当前线程数等于corePoolSize;如果当前线程数为corePoolSize,继续提交的任务被保存到阻塞队列中,等待被执行;如果执行了线程池的prestartAllCoreThreads()方法,线程池会提前创建并启动所有核心线程
maximumPoolSize:线程池中允许的最大线程数。如果当前阻塞队列满了,且继续提交任务,则创建新的线程执行任务,前提是当前线程数小于maximumPoolSize
keepAliveTime:线程池维护线程所允许的空闲时间。当线程池中的线程数量大于corePoolSize的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了keepAliveTime
unit:keepAliveTime的单位;可选的单位有天(DAYS)、小时(HOURS)、分钟(MINUTES)、毫秒(MILLISECONDS)、微秒(MICROSECONDS,千分之一毫秒)和纳秒(NANOSECONDS)
workQueue:用来保存等待被执行的任务的阻塞队列,且任务必须实现Runable接口,在JDK中提供了如下阻塞队列:
- ArrayBlockingQueue:基于数组结构的有界阻塞队列,按FIFO排序任务;
- LinkedBlockingQuene:基于链表结构的阻塞队列,按FIFO排序任务,吞吐量通常要高于ArrayBlockingQuene;
- SynchronousQuene:一个不存储元素的阻塞队列,每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于LinkedBlockingQuene;
- priorityBlockingQuene:具有优先级的无界阻塞队列;
threadFactory:它是ThreadFactory类型的变量,用来创建新线程。默认使用Executors.defaultThreadFactory() 来创建线程。使用默认的ThreadFactory来创建线程时,会使新创建的线程具有相同的NORM_PRIORITY优先级并且是非守护线程,同时也设置了线程的名称
handler:线程池的饱和策略,当阻塞队列满了,且没有空闲的工作线程,如果继续提交任务,必须采取一种策略处理该任务,线程池提供了4种策略:
上面的4种策略都是ThreadPoolExecutor的内部类。当然也可以根据应用场景实现RejectedExecutionHandler接口,自定义饱和策略,如记录日志或持久化存储不能处理的任务
- AbortPolicy:直接抛出异常,默认策略;
- CallerRunsPolicy:用调用者所在的线程来执行任务;
- DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务;
- DiscardPolicy:直接丢弃任务;
上面的4种策略都是ThreadPoolExecutor的内部类。当然也可以根据应用场景实现RejectedExecutionHandler接口,自定义饱和策略,如记录日志或持久化存储不能处理的任务
线程池工作原理
也就是答图上的流程
也就是答图上的流程
ThreadPoolExecutor采取上述步骤的总体设计思路,是为了在执行execute()方法时,尽可能地避免获取全局锁(那将会是一个严重的可伸缩瓶颈)。在ThreadPoolExecutor完成预热之后(当前运行的线程数大于等于corePoolSize),几乎所有的execute()方法调用都是执行步骤2,而步骤2不需要获取全局锁。
线程池中的线程执行任务分两种情况,如下:
1)在execute()方法中创建一个线程时,会让这个线程执行当前任务。
2)这个线程执行完上图中1的任务后,会反复从BlockingQueue获取任务来执行
1)在execute()方法中创建一个线程时,会让这个线程执行当前任务。
2)这个线程执行完上图中1的任务后,会反复从BlockingQueue获取任务来执行
介绍一下 Atomic 原子类
先说下介绍:Atomic就是帮助我们原子操作的工具
再说下能原子操作哪些:在Atomic包里一共有12个类,四种原子更新方式,分别是原子更新基本类型,原子更新数组,原子更新引用和原子更新字段。Atomic包里的类基本都是使用Unsafe实现的包装类。
JUC 包中的原子类是哪 4 类?
使用原子的方式更新基本类型
- AtomicInteger:整形原子类
- AtomicLong:长整型原子类
- AtomicBoolean:布尔型原子类
使用原子的方式更新数组里的某个元素
- AtomicIntegerArray:整形数组原子类
- AtomicLongArray:长整形数组原子类
- AtomicReferenceArray:引用类型数组原子类
引用类型
- AtomicReference:引用类型原子类
- AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。
- AtomicMarkableReference :原子更新带有标记位的引用类型
对象的属性修改类型
- AtomicIntegerFieldUpdater:原子更新整形字段的更新器
- AtomicLongFieldUpdater:原子更新长整形字段的更新器
- AtomicReferenceFieldUpdater:原子更新引用类型字段的更新器
AQS 了解么?
AQS定义了一套多线程访问共享资源的同步器框架,是一个依赖状态(state)的同步器,AQS同步器也就是资源控制,他们都是基于CAS,都会有通过实现AbstractQueuedSynchronized的对象来控制资源,实现自己想要的效果
AQS 原理了解么?
AQS 使用一个 int 成员变量来表示同步状态,通过内置的 FIFO 队列来完成获取资源线程的排队工作。AQS 使用 CAS 对该同步状态进行原子操作实现对其值的修改。
用过 CountDownLatch 么?什么场景下用的?
介绍下在大兴临时使用过,首先项目是归集很多子系统的数据,需要通过http请求,当时是因为一个同事把很多数据都放在一个接口中去了,也就是说在这个接口中存在很多个http请求数据,因为归集那边数据量大了,有些接口突然就变慢了,为了紧急解决所以当时使用了这个CountDownLatch来多线程获取数据在合并结果
公平与非公平原理
所谓公平指的是严格按照阻塞队列的顺序来获取锁。
如果是公平锁,就直接放入到队列尾部,如果是非公平锁上来先cas抢占下,抢不到自旋下,还是抢占不到再放入到队列尾部
如果是公平锁,就直接放入到队列尾部,如果是非公平锁上来先cas抢占下,抢不到自旋下,还是抢占不到再放入到队列尾部
condition实现原理
首先有一个等待队列跟同步队列,同步队列中的线程才可以去获取锁。当调用Condition 的await()方法的时候,会使当前线程进入等待队列并释放锁,同时线程状态变为等待状态,同时会放入到等待队列尾部中去。
当调用Condition的signal()方法,会将等待队列中的首节点放入到同步队列的尾结点等待被唤醒,抢占锁。调用signalAll()则是将等待队列中的节点依次加入到同步队列中去
conditon最常见的使用方式:生产消费者的模型
mysql
mysql主从架构
为什么要主从架构
- 如果主服务器出现问题,可以快速切换到从服务器提供的服务
- 可以在从服务器上执行查询操作,降低主服务器的访问压力
- 可以在从服务器上执行备份,以避免备份期间影响主服务器的服务
主从架构有哪些方案
主要看你怎么配置
- M-S: 一主一从
- M-S-S-S:一主多从
- M-M-M-S:多主一从,将每个主数据同步到一个从服务器上(汇总)
- MM:互为主从
- SSSS:环形架构
主从同步
第二步:slave开启一个I/O Thread,该线程在master打开一个普通连接,主要工作是binlog dump process。如果读取的进度已经跟上了master,就进入睡眠状态并等待master产生新的事件。I/O线程最终的目的是将这些事件写入到中继日志中。
第三步:SQL Thread会读取中继日志(这里是单线程,所以存在性能问题),并顺序执行该日志中的SQL事件,从而与主数据库中的数据保持一致。
- Binarylog:主数据库的二进制日志。Relaylog:从服务器的中继日志
第二步:slave开启一个I/O Thread,该线程在master打开一个普通连接,主要工作是binlog dump process。如果读取的进度已经跟上了master,就进入睡眠状态并等待master产生新的事件。I/O线程最终的目的是将这些事件写入到中继日志中。
第三步:SQL Thread会读取中继日志(这里是单线程,所以存在性能问题),并顺序执行该日志中的SQL事件,从而与主数据库中的数据保持一致。
数据同步方式:1、GTID(底层也是基于bin-log),2、bin-log
主从复制方式:
- 同步复制(Fullysynchronized):需要等到slave同步成功再commit返回
- 异步复制(Asyn)mysql默认的复制方式:master写入bin-log成功即commit返回
- 半同步复制:slave同步到relay-log再commit返回
MySQL数据库主从同步延迟是怎么产生的
slave将relay-log写入库中采用的单线程方式,如果写入的数据过多或者部分缓慢,因为单线程串行化原因,产生数据同步延迟
MySQL数据库主从同步延迟解决方案
- 最简单的减少slave同步延时的方案就是在架构上做优化,尽量让主库的DDL快速执行。
- 还有就是主库是写,对数据安全性较高,比如 sync_binlog=1,innodb_flush_log_at_trx_commit = 1 之类的设置,而slave则不需要这么高的数据安全,完全可以讲sync_binlog设置为0或者关闭binlog,innodb_flushlog也 可以设置为0来提高sql的执行效率。
- 另外就是使用比主库更好的硬件设备作为slave
innodb_flush_log_at_trx_commit 配置说明
默认值1的意思是每一次事务提交或事务外的指令都需要把日志写入(flush)硬盘,这是很费时的。特别是使用电 池供电缓存(Battery backed up cache)时。设成2对于很多运用,特别是从MyISAM表转过来的是可以的,它的意思是不写入硬盘而是写入系统缓存。日志仍然会每秒flush到硬 盘,所以你一般不会丢失超过1-2秒的更新。设成0会更快一点,但安全方面比较差,即使MySQL挂了也可能会丢失事务的数据。而值2只会在整个操作系统 挂了时才可能丢数据
默认值1的意思是每一次事务提交或事务外的指令都需要把日志写入(flush)硬盘,这是很费时的。特别是使用电 池供电缓存(Battery backed up cache)时。设成2对于很多运用,特别是从MyISAM表转过来的是可以的,它的意思是不写入硬盘而是写入系统缓存。日志仍然会每秒flush到硬 盘,所以你一般不会丢失超过1-2秒的更新。设成0会更快一点,但安全方面比较差,即使MySQL挂了也可能会丢失事务的数据。而值2只会在整个操作系统 挂了时才可能丢数据
MySQL数据库主从同步延迟原理
谈到MySQL数据库主从同步延迟原理,得从mysql的数据库主从复制原理说起,mysql的主从复制都是单线程的操作,主库对所有DDL和 DML产生binlog,binlog是顺序写,所以效率很高,slave的Slave_IO_Running线程到主库取日志,效率比较高,下一步, 问题来了,slave的Slave_SQL_Running线程将主库的DDL和DML操作在slave实施。DML和DDL的IO操作是随机的,不是顺序的,成本高很多,还可能在slave上的其他查询产生lock争用,由于Slave_SQL_Running也是单线程的,所以一个DDL卡主了,需要 执行10分钟,那么所有之后的DDL会等待这个DDL执行完才会继续执行,这就导致了延时。有朋友会问:“主库上那个相同的DDL也需要执行10分,为什 么slave会延时?”,答案是master可以并发,Slave_SQL_Running线程却不可以
1、主节点挂了后,从节点不会变成master,只是一直等待连上原来的master,但是能读;
2、而且master重启后bin-log文件会重新建另外一个文件(比如之前mysql-bin.000009,重启后mysql-bin.000010),从节点是会主动切换到mysql-bin.000010的
3、如果从节点没有库或者说没有表,master操作后从节点跟踪的bin-log偏移量也会跟着变,只是没表同步过去而已
高可用架构简单介绍
MMM高可用方案(基本淘汰了)
MMM(Master-Master replication manager for Mysql),Mysql主主复制管理器是一套灵活的脚本程序,基于perl实现,用来对mysql replication(复制)进行监控和故障迁移,并能管理mysql Master-Master复制的配置(同一时间只有一个节点是可写的)
MHA高可用方案
MHA服务,有两种角色, MHA Manager(管理节点)和 MHA Node(数据节点)。在MySQL故障切换过程中,MHA能做到在0~30秒之内自动完成数据库的故障切换操作,目前MHA主要支持一主多从的架构,要搭建MHA,要求一个复制集群中必须最少有三台数据库服务器
水平添加从服务器
当我们的服务器运行一段时间后,流量变得越来越多,这时,一主一从能够实现的高可用性和负载均衡不能满足我们的需求,我们就要选择再添加一台从服务器。可是现在我们的 master 已经运行很久了,我们也需要对新安装的 slave 进行数据同步,甚至它没有 master 的数据。此时,有几种方法可以使 slave 从另一个服务开始,例如,从 master 拷贝数据,从另一个 slave 克隆,从最近的备份开始一个 slave 。为了加快 slave 与 master 同步,可用以下方式先进行数据同步:
(1)master的某个时刻的数据快照(mysqldump命令);
(2)数据库的备份数据;
(3)master的二进制日志文件。
(1)master的某个时刻的数据快照(mysqldump命令);
(2)数据库的备份数据;
(3)master的二进制日志文件。
深入mysql索引底层数据结构与算法
索引数据结构
二叉树
二叉树是n(n>=0)个结点的有限集合,该集合或者为空集(称为空二叉树),或者由一个根结点和两棵互不相交的、分别称为根结点的左子树和右子树组成。
插入一个数据的时候它会先比较如果比这个数大就往右边走,直到走到最下面一层
插入一个数据的时候它会先比较如果比这个数大就往右边走,直到走到最下面一层
二叉树的特点:
- 1)每个结点最多有两颗子树,所以二叉树中不存在度大于2的结点。
- 2)左子树和右子树是有顺序的,次序不能任意颠倒。
- 3)即使树中某结点只有一棵子树,也要区分它是左子树还是右子树
二叉树的性质:
- 1)在二叉树的第i层上最多有2i-1 个节点 。(i>=1)
- 2)二叉树中如果深度为k,那么最多有2k-1个节点。(k>=1)
- 3)n0=n2+1 n0表示度数为0的节点数,n2表示度数为2的节点数。
- 4)在完全二叉树中,具有n个节点的完全二叉树的深度为[log2n]+1,其中[log2n]是向下取整
为什么mysql不使用二叉树为索引结构
新增的时候无序是没多大问题,如果说按照顺序新增,比如自增的时候,每次都往右边走,最后变成一个链表结构,跟全表扫描一样,并不能提高我们的性能
红黑树(二叉平衡树)
红黑树也是二叉查找树,所以又可叫二叉平衡树。我们知道,二叉查找树这一数据结构并不难,而红黑树之所以难是难在它是自平衡的二叉查找树,在进行插入和删除等可能会破坏树的平衡的操作时,需要重新自处理达到平衡状态。现在在脑海想下怎么实现?是不是太多情景需要考虑了?先来看下红黑树的定义和一些基本性质
- 所谓的平衡说的是不会造成结构线性化,规定叶子节点的高度差不超过1
红黑树定义和性质:
- 性质1:每个节点要么是黑色,要么是红色。
- 性质2:根节点是黑色。
- 性质3:每个叶子节点(NIL)是黑色。
- 性质4:每个红色结点的两个子结点一定都是黑色。
- 性质5:任意一结点到每个叶子结点的路径都包含数量相同的黑结点。
为什么mysql不使用二叉树为索引结构
在数据量少的情况下,查找似乎影响不是很大,但是当我们mysql数据量有500w数据呢?那这个红黑树的高度是不是会很高,就算均衡的插入可能也会达到几十个高度,查询效率自然会降低很多。从前面分析情况来看,减少磁盘IO的次数就必须要压缩树的高度,如果存在一种结构可以控制在几个高度,数据还能存储的多,那就解决了我们的问题---B-Tree
B-Tree(B树)
相当于红黑树的节点横向扩展放更多的数据(红黑树一个节点只有一个数据,B-Tree一个节点有多个数据),在存放相同数据的情况下,横向放的越多,高度也就会越低
m阶B-Tree满足以下条件:
- 每个节点最多拥有m个子树
- 根节点至少有2个子树
- 分支节点至少拥有m/2颗子树(除根节点和叶子节点外都是分支节点)
- 所有叶子节点都在同一层、每个节点最多可以有m-1个key,并且以升序排列
从查找过程中发现,B树的比对次数磁盘IO的次数与二叉树相差不了多少,所以这样看来并没有什么优势。但是仔细一看会发现,比对是在内存中完成中,不涉及到磁盘IO,耗时可以忽略不计。另外B树中一个节点可以存放很多的key(个数由树阶决定)。相同数量的key在B树中生成的节点要远远少于二叉树中的节点,相差的节点数量就等同于磁盘IO的次数。这样到达一定数量后,性能的差异就显现出来了
为什么mysql不使用B-Tree为索引结构
1、存入相同的数据,b-tree需要更大的height:每个数据1kb,也就是16kb的n次幂=数据量(n=height),比如我们b+tree中两千万数据height才等于3,这个呢?大于3吧,大概要6.多个幂【B-tree每个节点存的是数据,B+tree存的是索引,也就能存更多的索引数据】
2、B+tree中的页节点是存在双向地址指向的,这个在范围查找中很大用处,可以直接走索引
2、B+tree中的页节点是存在双向地址指向的,这个在范围查找中很大用处,可以直接走索引
B+Tree(B-Tree变种)【mysql】
B-Tree只是每个节点存多个数据,而B+Tree是每个节点存多个数据的索引(),数据存在最下层的叶子节点,最下层数据有序且不同叶子会指向下一个叶子
B+Tree特点:
- 有m个子树的节点包含有m个元素(B-Tree中是m-1)
- 根节点和分支节点中不保存数据,只用于索引,所有数据都保存在叶子节点中。也就是最下面那行包含了所有的数据
- 所有分支节点和根节点都同时存在于子节点中,在子节点元素中是最大或者最小的元素。
- 叶子节点会包含所有的关键字,以及指向数据记录的指针,并且叶子节点本身是根据关键字的大小从小到大顺序链接。
mysql中的索引结构
mysql中索引使用B+Tree的应用
- 非叶子节点不存储data,只存储索引(冗余),可以放更多的索引
- 叶子节点包含所有索引字段
- 叶子节点用指针连接,提高区间访问的性能,这里用了双向,标准的只有单向
- 最底下为叶子节点,存储我们的索引跟数据(叶子节点中包含所有数据);这个Data可能是行地址,也可能就是所有的行数据,这跟存储引擎有关,InnoDb为行数据
- 非叶子节点(第一第二行)只存储索引,保证可以放入更多的索引
- 在非叶子节点中间存储了下一个节点的地址,比如【15-56】这个节点的中间存放着【15-20-49】这个节点的地址,【15-20-49】又在【15-56】大小之间
- 两个叶子节点相当于双向链表一样存储了各自的地址:这个地址在范围查找的时候有很大的帮助,如果我找col>20的,那我只要找到col=20就可以了,然后就直接返回后面的数据(所以范围查找可以走索引),在B-Tree树就不行
- 叶子结点跟非叶子节点都是有序的,左<右,如果把下图红色框出来的单独拎出来也相当于一个二叉树结构,当数据在一个格子放不下的时候会把最小的那个提到冗余节点中
在delete、update的时候有时候会重新整理索引结构,这个可以在可视化网站上试试就知道了。如果说索引值存在大量的null,null会放在叶子节点的最左边,有大量null的时候去查找个人感觉索引这方面影响并不会很大,主要是其他原因,比如下面
允许null的字段需要额外的空间来保存字段Null到null标志位映射的对应关系,所以保存这个映射关系的null标志位长度并不是固定的。也就是null字段越多并不是越省空间。实际生产环境中应尽量减少canbenull的字段
1、从代码编写角度分许,数据插入的时候,的确可以带来方便,但是在读取数据的时候,会带来大量的数据非空检查,否则抛出NullPointException.
2、从数据库字段长度占用角度分析,相同长度字段,null比非null存储空间长度占用多1个字节,因为需要一个额外字段判断是否是null值.
3、从sql语句查询角度分析,null值列和其他列进行操作可能引发错误.
允许null的字段需要额外的空间来保存字段Null到null标志位映射的对应关系,所以保存这个映射关系的null标志位长度并不是固定的。也就是null字段越多并不是越省空间。实际生产环境中应尽量减少canbenull的字段
1、从代码编写角度分许,数据插入的时候,的确可以带来方便,但是在读取数据的时候,会带来大量的数据非空检查,否则抛出NullPointException.
2、从数据库字段长度占用角度分析,相同长度字段,null比非null存储空间长度占用多1个字节,因为需要一个额外字段判断是否是null值.
3、从sql语句查询角度分析,null值列和其他列进行操作可能引发错误.
查找数据的时候跟上面说的B-Tree一样,每次IO读取一页索引数据到内存,然后在内存中比较(二分查找,耗时忽略不计),也就是说我们这里查找数据最多也就3次IO,可想为什么索引能做到了吧,就算数据量大道两千万也是3次IO,一样很快,下面分析
几个问题
关于mysql中B+Tree能存多少数据计算
也就是上面第一第二行第三行的每个节点空间大小为16KB,这样我们就能大概估算出能存多大的索引数据,16kb=16*1024byte,比如我们存的是bigint类型的数据,占用8个字节,6字节保存下级节点地址,也就是一个节点能存16*1024/(8+6)=1170 个数据,上面两个一样的都是大概1170,最后那个数据页:比如我们用的是InnoDb引擎,也就是Data为行数据,又比如我们一行数据大概占用1kb(实际可能不到),那也就是一页能存16kb/1kb=16个数据。接下来我们就能大概算出来下面3个高度的B+Tree能存多少索引数据:1170*1170*16=21,902,400(两千一百多万数据),在查询的时候竟然也只是最多读取3次IO(比较用的是二分法查找而且在内存很快),在mysql中可能把所有的非叶子节点常驻于内存中(最大也就16kb*1170=18,720kb=18M而已),这样也就只读一次IO
所有这也能说明mysql使用16kb作为一页的原因,因为16kb一页就已经能做到千万库了
所有这也能说明mysql使用16kb作为一页的原因,因为16kb一页就已经能做到千万库了
关于四层B+tree数据量达到两百亿说明
在大概推算mysql两个非页子节点加一个页子节点能存多大数据量时算出大概能存两千万的数据,也就是说两千万的数据在查找的是依旧是一次IO(前两个非页子节点放内存前提)。那如果我多了一个非页子节点呢?那数据量不是2千万*1170=200亿的数据了?在B+Tree中因为第三个非页子节点没放内存的情况下,也就两次IO,不也是很快很快的吗?问题来了,那还要分库分表干嘛,两次IO解决两百亿数据。我知道有个原因是有些sql语句不能走索引从而太慢太ma慢,除了这么原因呢?(磁盘占用:2千万大概20g,2百亿大概2000g)
数据量太大插入删除也会有一定影响,还有数据量太大,意味着业务操作也会非常频繁,增删改查的开销也会越来越大,而且一个库里可能有很多表,一台服务器的资源(CPU、磁盘、内存、IO等)是有限的,所以数据量过大,超过千万一般都会分库分表。但是查询的效率按B+Tree来看还是很快的
数据量太大插入删除也会有一定影响,还有数据量太大,意味着业务操作也会非常频繁,增删改查的开销也会越来越大,而且一个库里可能有很多表,一台服务器的资源(CPU、磁盘、内存、IO等)是有限的,所以数据量过大,超过千万一般都会分库分表。但是查询的效率按B+Tree来看还是很快的
关于字符形式ASCLL码的比较方法
where name like ‘zhug%’为什么能走索引?比如name='zhuge' 在构建索引顺序的时候计算的是 'zhuge' 这整个词语的Ascll码又不是 只计算 'zhug' 这个的Ascll码之和!所以前面是zhug的未必是有序的(未必就是zhugXXXXX,可能weibo在zhugXX跟zhugMM的中间呢)。除非说是z-h-u-g-e这样计算的就还说的过去(85-65-98-74)
这里应该是这样的 在对字符排序的时候应该是按一个单词一个单词来排序的,第一个单词一样就判断第二个单词,并不是单词多就Ascll码排后面
这里应该是这样的 在对字符排序的时候应该是按一个单词一个单词来排序的,第一个单词一样就判断第二个单词,并不是单词多就Ascll码排后面
关于DATE_FORMAT(enter_date,'%Y-%m-%d')/ˈfɔːmæt/为什么不走索引
DATE_FORMAT(enter_date,'%Y-%m-%d')在计算后按理也是存在着顺序的,但是mysql没有优化起来。比如我可以这样DATE_FORMAT(enter_date,'%Y-%m-%d') = '2019-04-09 00:00:00',找到最左边的数据后,然后再一个一个的往右边找,直到找到'2019-04-10 xx:xx:xx'就停止找,毕竟也是有序的,mysql只认绝对有序不认相对有序
为什么mysql不使用B-Tree为索引结构
1、存入相同的数据,b-tree需要更大的height:每个数据1kb,也就是16kb的n次幂=数据量(n=height),比如我们b+tree中两千万数据height才等于3,这个呢?大于3吧,大概要6.多个幂【B-tree每个节点存的是数据,B+tree存的是索引,也就能存更多的索引数据】
2、B+tree中的页节点是存在双向地址指向的,这个在范围查找中很大用处,可以直接走索引
2、B+tree中的页节点是存在双向地址指向的,这个在范围查找中很大用处,可以直接走索引
mysql-Hash结构索引
对索引的key进行一次hash计算就可以定位出数据存储的位置
很多时候Hash索引要比B+ 树索引更高效
很多时候Hash索引要比B+ 树索引更高效
- 比如当没有hash冲突的时候一下子就能确定数据地址,而B+树可能需要几次的IO
- 它是没法范围查找的,范围查找走不了索引
- hash冲突问题影响不会很大
比如我要对col3这一列构建hash索引,会先对列值取通过hash()得到一个数字,然后放入到对应的hash结构中,如果存在hash相等的会形成一个链表,数据为Map<列值,行数据地址>。当取数据的时候也是 先计算出hash(),然后定位到hash表中的位置,如果有多个map,就遍历得到,然后再从磁盘中读取数据
聚集索引跟非聚集索引
聚集索引(聚簇索引)
聚集索引(clustered index)就是按照每张表的主键构造一棵B+树,同时叶子节点中存放的即为整张表的行记录数据(聚集索引-叶节点包含了完整的数据记录),也将聚集索引的叶子节点称为数据页。聚集索引的这个特性决定了索引组织表中数据也是索引的一部分。聚集不就是非稀疏吗,嘿嘿【每张表只能拥有一个聚集索引】
InnoDB索引实现主键索引为聚集索引,Secondary索引(二级索引)为非聚集索引(辅助索引),所以说不要理解为InnoDb都是聚集索引啊
InnoDB索引实现主键索引为聚集索引,Secondary索引(二级索引)为非聚集索引(辅助索引),所以说不要理解为InnoDb都是聚集索引啊
而且在我们InnoDb的表中,数据都是通过构建聚集索引构建的,也就是说id就是个索引,表数据文件本身就是按B+Tree组织的一个索引结构文件
聚集索引默认是主键,如果表中没有定义主键,InnoDB 会选择一个唯一且非空的索引代替。如果没有这样的索引,InnoDB 会隐式定义一个主键(类似oracle中的RowId)来作为聚集索引。如果已经设置了主键为聚集索引又希望再单独设置聚集索引,必须先删除主键,然后添加我们想要的聚集索引,最后恢复设置主键即可
聚集索引默认是主键,如果表中没有定义主键,InnoDB 会选择一个唯一且非空的索引代替。如果没有这样的索引,InnoDB 会隐式定义一个主键(类似oracle中的RowId)来作为聚集索引。如果已经设置了主键为聚集索引又希望再单独设置聚集索引,必须先删除主键,然后添加我们想要的聚集索引,最后恢复设置主键即可
InnoDb中的聚集索引文件结构
t_user_plan.frm:表结构文件
t_user_plan.ibd:索引跟数据放在一起(所有索引都是在这个文件)
t_user_plan.ibd:索引跟数据放在一起(所有索引都是在这个文件)
为什么建议InnoDB表必须建主键,并且推荐使用整型的自增主键?
为什么建议InnoDB表必须建主键?
在InnoDb中,数据的存储是放在.Ibd文件中的,也就是都会通过索引构建数据存储机构,当我们没有构建主键的时候,mysql会逐一去找全部不相同的一列用来构建,如果没有会通过添加隐藏列(类似row)去构建。我们知道mysql资源是非常宝贵的,这些我们能做的事情为何要去多消耗mysql的资源呢
在InnoDb中,数据的存储是放在.Ibd文件中的,也就是都会通过索引构建数据存储机构,当我们没有构建主键的时候,mysql会逐一去找全部不相同的一列用来构建,如果没有会通过添加隐藏列(类似row)去构建。我们知道mysql资源是非常宝贵的,这些我们能做的事情为何要去多消耗mysql的资源呢
并且推荐使用整型的自增主键?
关于自增:
- 在找数据的时候或者构建数据的时候都会存在比较大小的时候,整形比非整形比较大小快的多,而字符串会通过ASCll码计算后再比较,整形比其他的数据类型占用的大小小得多
关于自增:
- 如果表使用自增主键,那么每次插入新的记录,记录就会顺序添加到当前索引节点的后续位置,当一页写满,就会自动开辟一个新的页
- 如果使用非自增主键(如果身份证号或学号等),由于每次插入主键的值近似于随机,因此每次新纪录都要被插到现有索引页得中间某个位置,此时MySQL不得不为了将新记录插到合适位置而移动数据,甚至索引页因为变动需要从缓存中清掉,重新从磁盘上读回来,这增加了很多开销,同时频繁的移动、分页操作造成了大量的碎片,得到了不够紧凑的索引结构,后续不得不通过OPTIMIZETABLE来重建表并优化填充页面
为什么非主键索引结构中的叶子节点存储的是主键值?(一致性和节省存储空间)
如果每个二级索引都放真正的数据的话,那占用的空间太大了;而且每个索引下的数据一致性又是一个难以维护的问题。主要还是空间问题
非聚集索引(非聚簇)
辅助索引(Secondary Index,也称非聚集索引),叶子节点并不包含行记录的全部数据。叶子节点除了包含键值以外,每个叶子节点中的索引行中还包含了指向主键的指针。
辅助索引的存在并不影响数据在聚集索引中的组织,因此每张表上可以有多个辅助索引。当通过辅助索引来寻找数据时,InnoDB存储引擎会遍历辅助索引并通过叶级别的指针获得指向主键索引的主键,然后再通过聚集索引来找到一个完整的行记录(也就是回表查询操作,会根据这个id去查找到数据)
辅助索引的存在并不影响数据在聚集索引中的组织,因此每张表上可以有多个辅助索引。当通过辅助索引来寻找数据时,InnoDB存储引擎会遍历辅助索引并通过叶级别的指针获得指向主键索引的主键,然后再通过聚集索引来找到一个完整的行记录(也就是回表查询操作,会根据这个id去查找到数据)
MyISAM中文件结构
MyISAM索引文件和数据文件是分离的,是非聚集索引存储,数据存在的结构也是这样的:
- t_MyISAM_test.frm:表结构文件
- t_MyISAM_test.MYI:表索引文件,一个索引一个文件
- t_MyISAM_test.MYD:表数据文件
联合索引的底层存储结构长什么样?
有一个最左前缀的概念:会先比较第一个元素,如果第一个元素相等就比较第二个,以此类推。
上面图画的是主键索引(把他们设置成主键),因为下面放的是行数据,所以三个值都相等也并不会在同一个地方,二级索引就会,存放多个id。所以从这个也可以看出联合索引的是否能走索引。
为什么联合索引能不能走索引是这样的?因为在构建索引的时候就是通过先左再右的比较的,比如在<age,position>,在索引结构中是没有序的
上面图画的是主键索引(把他们设置成主键),因为下面放的是行数据,所以三个值都相等也并不会在同一个地方,二级索引就会,存放多个id。所以从这个也可以看出联合索引的是否能走索引。
为什么联合索引能不能走索引是这样的?因为在构建索引的时候就是通过先左再右的比较的,比如在<age,position>,在索引结构中是没有序的
为什么不直接在二级索引中存储数据的地址呢?
这会引入额外的复杂性,因为当数据移动或者进行修改时,这些地址也需要相应地更新。此外,存储地址可能会增加数据库的存储开销。通过引用主键而不是直接存储地址,可以简化数据的维护和管理。通常情况下,索引的设计取决于具体的数据库引擎和优化策略,不同的引擎可能会有不同的索引机制
Explain详解与索引最佳实践
Explain详解(第一时间结合索引树思考)
Explain工具介绍
使用EXPLAIN关键字可以模拟优化器执行SQL语句,分析你的查询语句或是结构的性能瓶颈 。
在 select 语句之前增加 explain 关键字,MySQL 会在查询上设置一个标记,执行查询会返回执行计划的信息,而不是执行这条SQL
注意:如果 from 中包含子查询,仍会执行该子查询,将结果放入临时表中
在 select 语句之前增加 explain 关键字,MySQL 会在查询上设置一个标记,执行查询会返回执行计划的信息,而不是执行这条SQL
注意:如果 from 中包含子查询,仍会执行该子查询,将结果放入临时表中
在查询中的每个表会输出一行,如果有两个表通过 join 连接查询,那么会输出两行
explain 两个变种
explain extended
会在 explain 的基础上额外提供一些查询优化的信息。紧随其后通过 show warnings 命令可以得到优化后的查询语句,从而看出优化器优化了什么。额外还有 filtered 列,是一个半分比的值,rows *filtered/100 可以估算出将要和 explain 中前一个表进行连接的行数(前一个表指 explain 中的id值比当前表id值小的表,这里只有一个表也只有一个id(如下))
- explain select * from film where id = 1;show warnings;
explain partitions
相比 explain 多了个 partitions 字段(早期版本),如果查询是基于分区表的话,会显示查询将访问的分区。(partitions 这个在早期的版本explain是不显示的,现在显示了)
explain结果中的列
id列
id列的编号是 select 的序列号,有几个 select 就有几个id,并且id的顺序是按 select 出现的顺序增长的。id列越大执行优先级越高,id相同则从上往下执行,id为NULL最后执行。
select_type列
select_type 表示对应行是简单还是复杂的查询。
- 1)simple:简单查询。查询不包含子查询和union
- 2)primary:复杂查询中最外层的 select
- 3)subquery(子查询):包含在 select 中的子查询(不在 from 子句中)
- 4)derived:包含在 from 子句中的子查询。MySQL会将结果存放在一个临时表中,也称为派生表(derived的英文含义)
- 5)union:在 union 中的第二个和随后的 select
table列
这一列表示 explain 的一行正在访问哪个表
当 from 子句中有子查询时,table列是 <derivenN> 格式,表示当前查询依赖 id=N 的查询(这里的id指的是explain的id),于是先执行 id=N 的查询。
当有 union 时,UNION RESULT 的 table 列的值为<union1,2>,1和2表示参与 union 的 select 行id
当 from 子句中有子查询时,table列是 <derivenN> 格式,表示当前查询依赖 id=N 的查询(这里的id指的是explain的id),于是先执行 id=N 的查询。
当有 union 时,UNION RESULT 的 table 列的值为<union1,2>,1和2表示参与 union 的 select 行id
type列
这一列表示关联类型或访问类型,即MySQL决定如何查找表中的行,查找数据行记录的大概范围
依次从最优到最差分别为:system > const > eq_ref > ref > range > index > ALL
一般来说,得保证查询达到range级别,最好达到ref
级别讲解
NULL:mysql能够在优化阶段分解查询语句,在执行阶段用不着再访问表或索引。例如:在索引列中选取最小值,可以单独查找索引来完成,不需要在执行时访问表。直接从叶子节点的最左边拿数据
- explain select min(id) from film;
const, system:mysql能对查询的某部分进行优化并将其转化成一个常量(可以看show warnings 的结果)。用于primary key 或 unique key 的所有列与常数比较时,所以表最多有一个匹配行,读取1次,速度比较快。system是const的特例,表里只有一条元组匹配时为system。也就是唯一性的就是const,然后表只有一个数据(指的是mysql在内存中就知道只有一条,而不是磁盘,比如下面film只有一条数据的情况是不会system的)就直接常量返回,为system
- explain select * from (select * from film where id = 1) tmp;
film只有一条数据不会是system,而外层的在内存中就已知只有一条记录所以为system
eq_ref:primary key 或 unique key 索引的所有部分被连接使用 ,最多只会返回一条符合条件的记录。这可能是在const 之外最好的联接类型了,简单的 select 查询不会出现这种 type
- explain select * from film_actor left join film on film_actor.film_id = film.id;
左表是需要全表扫描的,所以为all,然后再根据id去关联找film表,每个id'只有一个数据所有为eq_ref(你要想着where id=xx 的时候都是const级别了,因为每个去关联所有降低了点)
ref:相比 eq_ref,不使用唯一索引,而是使用普通索引或者唯一性索引的部分前缀,索引要和某个值相比较,可能会找到多个符合条件的行。
- 简单 select 查询,name是普通索引(非唯一索引)
- explain select * from film where name = 'film1';
range:范围扫描通常出现在 in(), between ,> ,<, >= 等操作中。使用一个索引来检索给定范围的行。
- explain select * from actor where id > 1;-- 在叶子节点中存在双向的关系,所以只需要定位到第一个然后依序找下去就行了
index:扫描全索引就能拿到结果,一般是扫描某个二级索引,这种扫描不会从索引树根节点开始快速查找,而是直接对二级索引的叶子节点遍历和扫描,速度还是比较慢的,这种查询一般为使用覆盖索引,二级索引一般比较小,所以这种通常比ALL快一些。(像jpa这种给我们自动写好的sql查找所有列,所以很多情况会用不到索引,所以数据量大都不推荐用)
ALL:即全表扫描,扫描你的聚簇索引的所有叶子节点。通常情况下这需要增加索引来进行优化了。
possible_keys列
这一列显示查询可能使用哪些索引来查找。
explain 有时可能出现 possible_keys 有列,而 key 显示 NULL 的情况,这种情况是因为表中数据不多,mysql认为索引对此查询帮助不大,选择了全表查询
explain 有时可能出现 possible_keys 有列,而 key 显示 NULL 的情况,这种情况是因为表中数据不多,mysql认为索引对此查询帮助不大,选择了全表查询
key列
这一列显示mysql实际采用哪个索引来优化对该表的访问。
如果没有使用索引,则该列是 NULL。如果想强制mysql使用或忽视possible_keys列中的索引,在查询中使用 force index、ignore index
如果没有使用索引,则该列是 NULL。如果想强制mysql使用或忽视possible_keys列中的索引,在查询中使用 force index、ignore index
key_len列
这一列显示了mysql在索引里使用的字节数,通过这个值可以算出具体使用了索引中的哪些列。
- 举例来说,film_actor的联合索引 idx_film_actor_id 由 film_id 和 actor_id 两个int列组成,并且每个int是4字节。通过结果中的key_len=4可推断出查询使用了第一个列:film_id列来执行索引查找。
ref列
这一列显示了在key列记录的索引中,表查找值所用到的列或常量,常见的有:const(常量),字段名(例:film.id)
rows列
这一列是mysql估计要读取并检测的行数,注意这个不是结果集里的行数。(执行器发送执行请求的次数,执行引擎可能一次执行不止扫描一次,见sql底层原理分析)
Extra列
这个东西不确定的,很多情况出现都搞不懂,很复杂,不用深究
- Using index:使用覆盖索引
- Using where:使用 where 语句来处理结果,并且查询的列未被索引覆盖
- Using index condition:查询的列不完全被索引覆盖,where条件中是一个前导列(就是联合索引的左列)的范围;
- Using temporary(临时的):mysql需要创建一张临时表来处理查询。出现这种情况一般是要进行优化的,首先是想到用索引来优化。
- Using filesort:将用外部排序而不是索引排序,数据较小时从内存排序,否则需要在磁盘完成排序(数据量太大了内存吃不消)。这种情况下一般也是要考虑使用索引来优化的(索引排序)
- Select tables optimized(使最优化;充分利用) away(离开):使用某些聚合函数(比如 max、min)来访问存在索引的某个字段
索引最佳实践
几个查询原则(第一时间结合索引树思考)
全值匹配
最左前缀法则
- 如果索引了多列,要遵守最左前缀法则。指的是查询从索引的最左前列开始并且不跳过索引中的列
不在索引列上做任何操作(计算、函数、(自动or手动)类型转换)
存储引擎不能使用索引中范围条件右边的列
-- age使用范围查找
EXPLAIN SELECT * FROM employees WHERE name= 'LiLei' AND age > 22 AND position ='manager';
通过索引树分析:这里用到了name跟age,position没有用到。找到第一个name= 'LiLei' AND age = 22后,name跟age中age是有序的,所以直接找,然后再在结果集中比较position ='manager';
EXPLAIN SELECT * FROM employees WHERE name= 'LiLei' AND age > 22 AND position ='manager';
通过索引树分析:这里用到了name跟age,position没有用到。找到第一个name= 'LiLei' AND age = 22后,name跟age中age是有序的,所以直接找,然后再在结果集中比较position ='manager';
尽量使用覆盖索引(只访问索引的查询(索引列包含查询列)),减少 select * 语句
mysql在使用不等于(!=或者<>)的时候无法使用索引会导致全表扫描
is null,is not null 一般情况下也无法使用索引
- 如果字段设置了不可为null,mysql直接返回空数据,而不去找表
like以%通配符开头('$abc...')mysql索引失效会变成全表扫描操作
问题:解决like'%字符串%'索引不被使用的方法?
a)使用覆盖索引,查询字段必须是建立覆盖索引字段
b)如果不能使用覆盖索引则可能需要借助搜索引擎
c)mysql支持全文索引,跟es一样为倒排索引
a)使用覆盖索引,查询字段必须是建立覆盖索引字段
b)如果不能使用覆盖索引则可能需要借助搜索引擎
c)mysql支持全文索引,跟es一样为倒排索引
字符串不加单引号索引失效
- name为字符串类型,使用数字索引会失效。所以需要保持跟索引类型一样
少用or或in
用它查询时,mysql不一定使用索引,mysql内部优化器会根据检索比例、表大小等多个因素整体评估是否使用索引,详见范围查询优化
EXPLAIN SELECT * FROM employees WHERE name = 'LiLei' or name = 'HanMeimei';
这里结合索引树说下:它会找name = 'LiLei'的结果集+name = 'HanMeimei'的结果集,数据少还不如ALL,所以mysql选了ALL
EXPLAIN SELECT * FROM employees WHERE name = 'LiLei' or name = 'HanMeimei';
这里结合索引树说下:它会找name = 'LiLei'的结果集+name = 'HanMeimei'的结果集,数据少还不如ALL,所以mysql选了ALL
范围查询优化
可以业务控制,一页一页的查
联合索引各个情况使用到的索引图
Mysql事务隔离级别与锁机制
事务
事务及其ACID属性
原子性(Atomicity):原子性操作可以理解为把一个事务中个多个操作看成为一个操作一样,只存在全部成功跟全部失败的可能
一致性(Consistent):原子性站在操作层面(多个操作看成一个操作),一致性为站在数据的层面上--事务的各个操作都修改数据成功,也就是数据从一个状态到另一个状态必须是一致的,比如A向B转账(A、B的总金额就是一个一致性状态),不可能出现A扣了钱,B却没收到的情况发生
隔离性(Isolation):事务处理过程中的中间状态对外部是不可见的,数据的隔离性是针对并发事务而言,非并发事务是天然隔离的。并发事务是指两个事务操作了同一份数据的情况;
持久性(Durable):事务完成之后,它对于数据的修改是永久性的,即使出现系统故障也能够保持
并发事务处理带来的问题
更新丢失(Lost Update)(脏写)
当两个或多个事务选择同一行,然后基于最初选定的值更新该行时,由于每个事务都不知道其他事务的存在,就会发生丢失更新问题–最后的更新覆盖了由其他事务所做的更新。
比如:一开始 Stock=10,A事务先更改为Stock=10-1=9,接着B事务更改为Stock=10-2=8,按理本来库存应该经过两次后为10-3=7,结果前面A的更改丢失了。
比如:一开始 Stock=10,A事务先更改为Stock=10-1=9,接着B事务更改为Stock=10-2=8,按理本来库存应该经过两次后为10-3=7,结果前面A的更改丢失了。
脏读(Dirty Reads)
一个事务正在对一条记录做修改,在这个事务完成并提交前,这条记录的数据就处于不一致的状态;这时,另一个事务也来读取同一条记录,如果不加控制,第二个事务读取了这些“脏”数据,并据此作进一步的处理,就会产生未提交的数据依赖关系。这种现象被形象的叫做“脏读”。
一句话:事务A读取到了事务B已经修改但尚未提交的数据,还在这个数据基础上做了操作。此时,如果B事务回滚,A读取的数据无效,因为不符合隔离性造成数据不一致问题
一句话:事务A读取到了事务B已经修改但尚未提交的数据,还在这个数据基础上做了操作。此时,如果B事务回滚,A读取的数据无效,因为不符合隔离性造成数据不一致问题
不可重读(Non-Repeatable Reads)
一个事务在读取某些数据后的某个时间,再次读取以前读过的数据,却发现其读出的数据已经发生了改变、或某些记录已经被删除了!这种现象就叫 做“不可重复读”。
一句话:事务A内部的相同查询语句在不同时刻读出的结果不一致,不符合隔离性
一句话:事务A内部的相同查询语句在不同时刻读出的结果不一致,不符合隔离性
幻读(Phantom Reads)
一个事务按相同的查询条件重新读取以前检索过的数据,却发现其他事务插入了满足其查询条件的新数据,这种现象就称为“幻读”。
一句话:事务A读取到了事务B提交的新增数据,不符合隔离性。
幻读也属于不可重读的一种,只是幻读说的是读到B事务新增的数据,而上面的不可重读读的是B事务更新或删除的数据,造成在一个事务中不同时刻读取的数据不一致。
一句话:事务A读取到了事务B提交的新增数据,不符合隔离性。
幻读也属于不可重读的一种,只是幻读说的是读到B事务新增的数据,而上面的不可重读读的是B事务更新或删除的数据,造成在一个事务中不同时刻读取的数据不一致。
事务隔离级别
数据库的事务隔离越严格,并发副作用越小,但付出的代价也就越大,因为事务隔离实质上就是使事务在一定程度上“串行化”进行,这显然与“并发”是矛盾的。同时,不同的应用对读一致性和事务隔离程度的要求也是不同的,比如许多应用对“不可重复读"和“幻读”并不敏感,可能更关心数据并发访问的能力
Mysql默认的事务隔离级别是可重复读,用Spring开发程序时,如果不设置隔离级别默认用Mysql设置的隔离级别,如果Spring设置了就用spring设置的隔离级别
Mysql默认的事务隔离级别是可重复读,用Spring开发程序时,如果不设置隔离级别默认用Mysql设置的隔离级别,如果Spring设置了就用spring设置的隔离级别
四种隔离级别
Read Uncommitted(读未提交内容)
可以读取到未提交的数据。在该隔离级别,所有事务都可以看到其他事务未提交的结果。本隔离级别很少用于实际应用,因为它的性能也不比其他级别好多少。读取未提交的数据,也被称之为脏读(Dirty Read)
Read Committed(读已提交内容)
只有已提交的数据才能读取到。这是大多数数据库系统的默认隔离级别(但不是MySQL默认的)。它满足了隔离的简单定义:一个事务只能看见已经提交事务所做的改变。这种隔离级别 也会有不可重复读(Nonrepeatable Read),因为同一事务的其他实例在该实例处理其间可能会有新的commit,所以同一select可能返回不同结果
Repeatable Read(可重复读)
不同时刻读取的结果一样。这是MySQL的默认事务隔离级别,它确保同一事务的多个实例在并发读取数据时,会看到同样的数据行。不过理论上,这会导致另一个棘手的问题:幻读 (Phantom Read)。简单的说,幻读指当用户读取某一范围的数据行时,另一个事务又在该范围内插入了新行,当用户再读取该范围的数据行时,会发现有新的“幻影” 行。InnoDB和Falcon存储引擎通过多版本并发控制(MVCC,Multiversion Concurrency Control)机制解决了该问题
Serializable(可串行化)
这是最高的隔离级别,它通过强制事务排序,使之不可能相互冲突,从而解决幻读问题。简言之,它是在每个读的数据行上加上共享锁。在这个级别,可能导致大量的超时现象和锁竞争
数据的隔离级别是针对会话的(也就是连接)
可重复读级别下出现幻读解释
其他的都好理解,但是在可重复读中为什么可能出现幻读这个不好理解,这里说下。先说结论:事务B新增数据并提交,事务A并不会读取到事务B新增的数据,但是只要事务Aupdate事务B新增的记录操作后(因为事务B已提交自然可以update到),再次读取就能读取到事务B新增的数据了,也就出现了幻读
可串行化下的加锁避免幻读说明
在串行模式下innodb的查询也会被加上行锁,如果客户端A执行的是一个范围查询,那么该范围内的所有行,包括每行记录所在的间隙区间范围(就算该行数据还未被插入也会加锁,这种是间隙锁)都会被加行锁。此时如果客户端B在该范围内插入数据都会被阻塞,所以就避免了幻读。
MyISAM存储引擎默认在select加读锁(表锁),写时加写锁(表锁),加上它不支持事务begin-commit,所以它压根就不需要隔离级别,这本身就相当于默认串行化了
说说我对事务隔离级别的理解吧
- 首先非并发事务场景根本不需要事务隔离级别,事务隔离级别正是为了解决并发事务带来的脏读、不可重复读、幻读问题,而这些问题说到底都是因为没有满足事务ACID中的隔离性而造成的。既然这些问题都是因为并发造成的,那么不管什么并发问题都是可以通过锁来解决,那为什么不直接通过锁来解决这类问题呢?因为不同业务场景可能对不同问题敏感程度不同,所以mysql细分出4中隔离级别来满足不同的业务需求
- 事务隔离级别也是针对InnoDb来讲的,InnoDb默认(非串行化隔离级别)在select的时候不会加锁,update的时候才会加行锁(不同事务读不会阻塞,写同一条会阻塞),有这个默认的加锁机制也保证了写其实不会出现并发问题,所以上面的都是读而产生的问题
- 既然说它没有直接采用锁来解决并发问题,那读已提交跟可重复读两个级别是怎么实现的呢?答案就是MVCC多版本并发控制来达到目的,它会通过read-view跟undo版本链对比机制来使得不同事务在不同的隔离级别中读取到它们该读的数据
锁
锁分类
从性能上分为乐观锁(用版本对比来实现)和悲观锁
- 悲观锁,顾名思义就是很悲观,每次拿数据时都假设有别人会来修改,所以每次在拿数据的时候都会给数据加上锁,用这种方式来避免跟别人冲突,虽然很有效,但是可能会出现大量的锁冲突,导致性能低下。我没有读完的时候,不允许你来写,万一我读的慢你写的快,我读取的结果不是不对了
- 乐观锁则是完全相反,每次去拿数据的时候都认为别人不会修改,所以不会上锁,为了避免脏写所以需要在更新的时候会判断一下在此期间别人有没有改过这个数据,可以使用版本号等机制来判断
从对数据库操作的类型分,分为读锁和写锁(都属于悲观锁)
- 读锁(乐观锁):针对同一份数据,多个读操作可以同时进行而不会互相影响
- 写锁(悲观锁):当前写操作没有完成前,它会阻断其他写和读
从对数据操作的粒度分,分为表锁和行锁
表锁跟行锁
表锁(分为读锁跟写锁)
InnoDb跟MyISAM都支持
InnoDb跟MyISAM都支持
表锁概述
每次操作锁住整张表。开销小,加锁快;不会出现死锁;锁定粒度大,发生锁冲突的概率最高,并发度最低;一般用在整表数据迁移的场景保证说迁移的数据不会变化。
表锁基本操作
手动增加表锁
lock table 表名称 read(write),表名称2 read(write);
查看表上加过的锁
show open tables;
删除表锁
unlock tables;
读锁
当前session和其他session都可以读该表、当前session中插入或者更新锁定的表都会报错,其他session插入或更新则会等待(当删除表锁的时候commit);
简单就是读锁只能读不能写,连自己都不能写
简单就是读锁只能读不能写,连自己都不能写
写锁
当前session对该表的增删改查都没有问题,其他session对该表的所有操作被阻塞(只有当写锁释放后);
简单就是写锁只能本会话读写,其他会话不能读写(如果其他会话也能读的话那数据可能会不一样了,所以干脆就不能读写)
简单就是写锁只能本会话读写,其他会话不能读写(如果其他会话也能读的话那数据可能会不一样了,所以干脆就不能读写)
结论
1、对MyISAM表的读操作(加读锁) ,不会阻寒其他进程对同一表的读请求,但会阻赛对同一表的写请求。只有当读锁释放后,才会执行其它进程的写操作。
2、对MylSAM表的写操作(加写锁) ,会阻塞其他进程对同一表的读和写操作,只有当写锁释放后,才会执行其它进程的读写操作
2、对MylSAM表的写操作(加写锁) ,会阻塞其他进程对同一表的读和写操作,只有当写锁释放后,才会执行其它进程的读写操作
行锁(只有InnoDb有)
行锁概述
每次操作锁住一行数据。开销大,加锁慢(每一行都需要加锁,自然大跟慢);会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度最高。
InnoDB与MYISAM的最大不同有两点
InnoDB支持事务(TRANSACTION),MYISAM不支持事务-不能用begin-commit这些;
InnoDB支持行级锁
一个session开启事务更新不提交,另一个session更新同一条记录会阻塞,更新不同记录不会阻塞,查询不会阻塞
行级锁是加在索引上的,如果是主键索引,则直接锁定匹配的记录行。如果是二级索引,则先锁定匹配的二级索引记录,再根据id锁定对应的主键索引
总结
MyISAM在执行查询语句SELECT前,会自动给涉及的所有表加读锁,在执行update、insert、delete操作会自动给涉及的表加写锁。(因为MyISAM没有事务每次都是自动提交所以肉眼看不出来)
InnoDB在执行查询语句SELECT时(非串行隔离级别),不会加锁。但是update、insert、delete操作会加行锁。
简而言之,就是读锁会阻塞写,但是不会阻塞读。而写锁则会把其他事务读和写都阻塞
InnoDB在执行查询语句SELECT时(非串行隔离级别),不会加锁。但是update、insert、delete操作会加行锁。
简而言之,就是读锁会阻塞写,但是不会阻塞读。而写锁则会把其他事务读和写都阻塞
锁的其他讲解
间隙锁(Gap Lock)
间隙锁是在可重复读以上隔离级别下才会生效(update情况下会默认加行锁)
表中存在id(1、2、3、10、20),那么间隙就有 id 为 [3,10),[10,20),[20,正无穷) 这三个区间
在Session_1下面执行 update account set name = 'zhuge' where id > 8 and id <18;,则其他Session没法在这个范围所包含的所有行记录(包括间隙行记录)以及行记录所在的间隙里插入或修改任何数据,即id在(3,20]区间都无法修改数据,注意最后那个20也是包含在内的
在Session_1下面执行 update account set name = 'zhuge' where id > 8 and id <18;,则其他Session没法在这个范围所包含的所有行记录(包括间隙行记录)以及行记录所在的间隙里插入或修改任何数据,即id在(3,20]区间都无法修改数据,注意最后那个20也是包含在内的
临键锁(Next-key Locks)
Next-Key Locks是行锁与间隙锁的组合。像上面那个例子里的这个(3,20]的整个区间可以叫做临键锁。(上面我们是 id > 8 and id <18;,然后它会在(3,20]加锁,这个(3,20]就是临键锁
无索引行锁会升级为表锁
InnoDB的行锁是针对索引加的锁,不是针对记录加的锁。并且该索引不能失效,否则都会从行锁升级为表锁。
lock in share mode(共享锁) 和for update(排它锁)
从对数据库操作的类型分,分为读锁和写锁(都属于悲观锁:都是我在读的时候不允许你来写)
- 读锁(共享锁,S锁(Shared)):针对同一份数据,多个读操作可以同时进行而不会互相影响
- 写锁(排它锁,X锁(eXclusive)):当前写操作没有完成前,它会阻断其他写锁和读锁
锁定某一行还可以用lock in share mode(共享锁) 和for update(排它锁)
排它锁跟共享锁的区别在于,排它锁加锁之后,其他事务再给同一记录加排它锁会被阻塞,而共享锁其他事务可以给同一记录加共享锁
这里发现这个共享行锁跟前面的共享表锁lock table 表名称 read(write),表名称2 read(write);是不同的,前面写锁连读都不可以读,这里可以读(跟行锁一样,可读不可写,这么去理解:行锁相当于行级读锁)
- 排它锁:一个事务加了排它锁后未提交,其他事务读不会阻塞,其他事务加排它锁、修改都会阻塞;
- 共享锁:一个事务加了共享锁后未提交,其他事务读不会阻塞,加共享锁也不会阻塞,但是写会阻塞;
排它锁跟共享锁的区别在于,排它锁加锁之后,其他事务再给同一记录加排它锁会被阻塞,而共享锁其他事务可以给同一记录加共享锁
这里发现这个共享行锁跟前面的共享表锁lock table 表名称 read(write),表名称2 read(write);是不同的,前面写锁连读都不可以读,这里可以读(跟行锁一样,可读不可写,这么去理解:行锁相当于行级读锁)
Innodb行锁跟MYISAM表锁结论
Innodb存储引擎由于实现了行级锁定(InnoDB在执行查询语句SELECT时(非串行隔离级别),不会加锁。但是update、insert、delete操作会加行锁。),虽然在锁定机制的实现方面所带来的性能损耗可能比表级锁定会要更高一下,但是在整体并发处理能力方面要远远优于MYISAM的表级锁定的(MyISAM在执行查询语句SELECT前,会自动给涉及的所有表加读锁,在执行update、insert、delete操作会自动给涉及的表加写锁)。当系统并发量高的时候,Innodb的整体性能和MYISAM相比就会有比较明显的优势了。但是,Innodb的行级锁定同样也有其脆弱的一面,当我们使用不当的时候,可能会让Innodb的整体性能表现不仅不能比MYISAM高(可能变成表锁),甚至可能会更差
行锁分析
通过检查InnoDB_row_lock状态变量来分析系统上的行锁的争夺情况
show status like '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: 系统启动后到现在总共等待的次数
尤其是当等待次数很高,而且每次等待时长也不小的时候,我们就需要分析系统中为什么会有如此多的等待,然后根据分析结果着手制定优化计划。
系统库锁相关数据表
-- 查看事务
select * from INFORMATION_SCHEMA.INNODB_TRX;
-- 查看锁
select * from INFORMATION_SCHEMA.INNODB_LOCKS;
select * from INFORMATION_SCHEMA.INNODB_LOCKS;
-- 查看锁等待
select * from INFORMATION_SCHEMA.INNODB_LOCK_WAITS;
-- 释放锁,trx_mysql_thread_id可以从INNODB_TRX表里查看到
kill trx_mysql_thread_id
kill trx_mysql_thread_id
-- 查看锁等待详细信息(可用来分析死锁)
show engine innodb status\G;
show engine innodb status\G;
死锁
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;//执行失败,自动预测到死锁,不让它发生
大多数情况mysql可以自动检测死锁并回滚产生死锁的那个事务,但是有些情况mysql没法自动检测死锁
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;//执行失败,自动预测到死锁,不让它发生
大多数情况mysql可以自动检测死锁并回滚产生死锁的那个事务,但是有些情况mysql没法自动检测死锁
锁优化建议
- 尽可能让所有数据检索都通过索引来完成,避免无索引行锁升级为表锁(在InnoDb中,只有update才会加锁,也就可能升级为表锁)
- 合理设计索引,尽量缩小锁的范围(通过减少间隙锁)
- 尽可能减少检索条件范围,避免间隙锁(比如间隙锁这种,范围小了对应的临键锁范围就小了,加锁的行就少--针对InnoDb)
- 尽量控制事务大小,减少锁定资源量和时间长度,涉及事务加锁的sql尽量放在事务最后执行(因为越后面执行也就离commit越近啊)
- 尽可能低级别事务隔离(数据库的事务隔离越严格,并发副作用越小,但付出的代价也就越大)
总结记忆
表锁(分为读锁跟写锁)
InnoDb跟MyISAM都支持
InnoDb跟MyISAM都支持
表锁基本操作
手动增加表锁
lock table 表名称 read(write),表名称2 read(write);
查看表上加过的锁
show open tables;
删除表锁
unlock tables;
读锁
当前session和其他session都可以读该表、当前session中插入或者更新锁定的表都会报错,其他session插入或更新则会等待(当删除表锁的时候commit);
简单就是读锁只能读不能写,自己也不能写
简单就是读锁只能读不能写,自己也不能写
写锁
当前session对该表的增删改查都没有问题,其他session对该表的所有操作被阻塞(只有当写锁释放后);
简单就是写锁只能本会话读写,其他会话不能读写(如果其他会话也能读的话那数据可能会不一样了,所以干脆就不能读写)
简单就是写锁只能本会话读写,其他会话不能读写(如果其他会话也能读的话那数据可能会不一样了,所以干脆就不能读写)
行锁(只有InnoDb有)
一个session开启事务更新不提交,另一个session更新同一条记录会阻塞,更新不同记录不会阻塞,查询不会阻塞
如果涉及到范围,又存在间隙锁,间隙锁只在行锁中存在
lock in share mode(共享锁) 和for update(排它锁)
锁定某一行还可以用lock in share mode(共享锁) 和for update(排它锁)
排它锁跟共享锁的区别在于,排它锁加锁之后,其他事务再给同一记录加排它锁会被阻塞,而共享锁其他事务可以给同一记录加共享锁
- 排它锁:一个事务加了排它锁后未提交,其他事务读不会阻塞,其他事务加排它锁、修改都会阻塞;
- 共享锁:一个事务加了共享锁后未提交,其他事务读不会阻塞,加共享锁也不会阻塞,但是写会阻塞;
排它锁跟共享锁的区别在于,排它锁加锁之后,其他事务再给同一记录加排它锁会被阻塞,而共享锁其他事务可以给同一记录加共享锁
默认加锁方式
MyISAM在执行查询语句SELECT前,会自动给涉及的所有表加读锁,在执行update、insert、delete操作会自动给涉及的表加写锁。(因为MyISAM没有事务每次都是自动提交所以肉眼看不出来)
InnoDB在执行查询语句SELECT时(非串行隔离级别),不会加锁。但是update、insert、delete操作会加行锁。
InnoDB在执行查询语句SELECT时(非串行隔离级别),不会加锁。但是update、insert、delete操作会加行锁。
深入理解MVCC与BufferPool缓存机制
MVCC多版本并发控制机制
MVCC多版本并发控制机制介绍
Mysql在可重复读隔离级别下如何保证事务较高的隔离性,上一篇讲过,同样的sql查询语句在一个事务里多次执行查询结果相同,就算其它事务对数据有修改也不会影响当前事务sql语句的查询结果。这个隔离性就是靠MVCC(Multi-Version Concurrency Control)机制来保证的,对一行数据的读操作默认是不会通过加锁互斥来保证隔离性,避免了频繁加锁互斥,而在串行化隔离级别为了保证较高的隔离性是通过将所有操作加锁互斥来实现的
Mysql在读已提交和可重复读隔离级别下都实现了MVCC机制
undo日志版本链与read view机制(只在InnoDb上存在)
undo日志版本链
undo日志版本链是指一行数据被多个事务依次修改过后,在每个事务修改完后,Mysql会保留修改前的数据(undo回滚日志),并且用两个隐藏字段trx_id和roll_pointer把这些undo日志串联起来形成一个历史记录版本链(见图)
对于删除的情况可以认为是update的特殊情况,会将版本链上最新的数据复制一份,然后将trx_id修改成删除操作的trx_id,同时在该条记录的头信息(record header)里的(deleted_flag)标记位写上true,来表示当前记录已经被删除,在查询时按照上面的规则查到对应的记录如果delete_flag标记位为true,意味着记录已被删除,则不返回数据
read view机制
在可重复读隔离级别,当事务开启,执行任何查询sql时会生成当前事务的一致性视图read-view,该视图在事务结束之前都不会变化(如果是读已提交隔离级别在每次执行查询sql时都会重新生成),这个视图由执行查询时所有未提交事务id数组(其中数组里最小的id为min_id)和已创建的最大事务id(max_id)组成【分为两块:一块为所有未提交的id数组,一块为最大事务id】,事务里的任何sql查询结果需要从对应版本链里的最新数据开始逐条跟read-view做比对从而得到最终的快照结果。(使用最小txid跟最大txid划分出三块,每次在这三块中比较)
undo版本链比对规则
- 如果 row 的 trx_id 落在绿色部分( trx_id<min_id ),表示这个版本是已提交的事务生成的,这个数据是可见的;--min_id 为当前未提交事务id中最小的,所以小于min_id 的事务id肯定是已经commit的事务id
- 如果 row 的 trx_id 落在红色部分( trx_id>max_id ),表示这个版本是由将来启动的事务生成的,是不可见的(若 row 的 trx_id 就是当前自己的事务是可见的);
- 如果 row 的 trx_id 落在黄色部分(min_id <=trx_id<= max_id),那就包括两种情况
- 若 row 的 trx_id 在视图数组中,表示这个版本是由还没提交的事务生成的,不可见(若 row 的 trx_id 就是当前自己的事务是可见的);
- 若 row 的 trx_id 不在视图数组中,表示这个版本是已经提交了的事务生成的,可见。-- 视图数组
注意:begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个修改操作InnoDB表的语句,事务才真正启动,才会向mysql申请事务id,mysql内部是严格按照事务的启动顺序来分配事务id的且事务id为递增的
总结:MVCC机制的实现就是通过read-view机制与undo版本链比对机制,使得不同的事务会根据数据版本链对比规则读取同一条数据在版本链上的不同版本数据。
Innodb引擎SQL执行的BufferPool缓存机制
以一个update案例看InnoDb中的bufferPool缓存机制图
客户端执行
update account set name = 'libo666' where id = 1; //id=1的name原始值为libo
update account set name = 'libo666' where id = 1; //id=1的name原始值为libo
- 1、如果bufferPool池(数据库的增删改查都是操作bufferPool中的数据,内存大小一般设置为机器内存的60%)中有id=1的数据直接用即可,如果没有需要从idb文件中加载id=1数据所在的page页至bufferPool缓存中;
- 2、写入更新数据的旧值到undo版本链日志(InnoDb特有)中。如果事务提交失败可以使用undo日志恢复bufferPool池中的数据;
- 3、更改bufferPool池中的数据为新数据name=libo666;
- 4、同时会将新数据写入到redo log bufferPool池中;
- 5、当提交事务的时候,将redo log bufferPool中的日志写入磁盘中的redo日志文件(InnoDb特有,如果事务提交成功后,bufferPool的数据没来得及写入磁盘的idb文件中,此时宕机了,可以使用redo日志文件恢复bufferPool的缓存数据),也就是将新数据写入redo日志文件中(redo日志都是顺序写入的,速度非常快,而且是特定大小的文件,写满了就再创建一个文件);
- 6、当提交事务同时,将新数据写入binlog日志文件(属于server层,主要用于恢复数据);
- 7、写入binlog成功后,写入commit标记(用于保证redo日志跟binlog日志数据一致)到redo文件中,事务到这里算提交完成;
- 8、后台会有IO线程,随机将bufferPool池中的数据以page页为单位写入到磁盘中,这步完成后磁盘数据才为新数据libo666
为什么Mysql不能直接更新磁盘上的数据而且设置这么一套复杂的机制来执行SQL了?
- 因为来一个请求就直接对磁盘文件进行随机读写,然后更新磁盘文件里的数据,性能可能相当差。
- 因为磁盘随机读写的性能是非常差的,所以直接更新磁盘文件是不能让数据库抗住很高并发的。
- Mysql这套机制看起来复杂,但它可以保证每个更新请求都是更新内存BufferPool,然后顺序写日志文件,同时还能保证各种异常情况下的数据一致性。
- 更新内存的性能是极高的,然后顺序写磁盘上的日志文件的性能也是非常高的,要远高于随机读写磁盘文件。
MySQL的缓冲池管理器使用了一种称为LRU(Least Recently Used,最近最少使用)的机制。当需要淘汰某些数据块以便为新数据块腾出空间时,MySQL会丢弃最近最少使用的数据块。
redo log 与 binlog 的同步(因为binlog不属于查询引擎层,属于server层,所以他们需要同步commit标识)
为了保证数据的一致性和耐久性,InnoDB存储引擎和MySQL服务器层在处理事务时需要同步redo log和binlog。
采用两阶段提交
采用两阶段提交
1.事务开始执行,数据库的修改操作首先写入redo log的Log Buffer。
2.当事务提交时,InnoDB会遵循两阶段提交协议来确保binlog和redo log的同步:
这种两阶段提交机制确保了即使在系统崩溃的情况下,redo log和binlog也能保持一致性,从而保证了数据库的一致性和可靠性
2.当事务提交时,InnoDB会遵循两阶段提交协议来确保binlog和redo log的同步:
- 第一阶段:将redo log从Log Buffer刷入redo log files,确保这些修改被持久化到磁盘。然后事务会被标记为prepare状态。
- 第二阶段:MySQL服务器层记录相应的修改到binlog。完成后,InnoDB会将事务从prepare状态改为commit状态,此时事务被认为是提交成功的。
这种两阶段提交机制确保了即使在系统崩溃的情况下,redo log和binlog也能保持一致性,从而保证了数据库的一致性和可靠性
总结
在 MySQL 中,InnoDB 存储引擎使用一块称为缓冲池(Buffer Pool)的内存区域,来缓存频繁访问的数据,以减少对磁盘I/O的依赖,提高数据库性能。缓冲池的有效管理对于数据库的整体性能是非常关键的。以下是一些有关于 InnoDB 缓冲池内存管理的要点:
缓冲池的作用
数据页缓存:当一个数据页(如:表的数据和索引)被加载到 Buffer Pool 时,InnoDB 可以直接从内存中访问它,而不是从磁盘中读取。这大大加速了数据检索。
脏页的刷新:当事务修改了缓存的数据页时,这些数据页被标记为"脏页"。这些脏页最终会在一定时机(如:事务提交、检查点创建或者背景写入任务触发时)被刷新回磁盘。
Change Buffer:对非唯一非聚簇索引的修改不会立即写入磁盘,而是缓存到 Buffer Pool 中的 Change Buffer 区域,当这一部分的数据页被加载到 Buffer Pool 时,变更才会被合并并应用到页上。
读写冲突减少:Buffer Pool 提供了一种机制来管理对数据的并发访问,包括锁和数据一致性。
内存分配与管理
大小设置:Buffer Pool 的大小由 innodb_buffer_pool_size 配置参数控制。建议将其设置为可用内存的大部分,但要为操作系统和其他服务器进程留出足够空间。
多个 Buffer Pool 实例:可以将 Buffer Pool 分割为多个独立的实例,通过 innodb_buffer_pool_instances 配置。这有助于减少争用并改善多核系统上的性能。
LRU 算法:Buffer Pool 使用最近最少使用(LRU)列表维护页面。最常访问的页位于列表的前端,而最少访问的页位于列表后端,从而可以在需要时逐出。
内存压缩,预读和刷新策略
内存压缩:InnoDB 可以缩放页来压缩数据,从而在Buffer Pool中存储更多的数据。
预读:InnoDB 使用预读机制自动或手动预加载可能即将被查询的数据页。
脏页刷新策略:通过 innodb_flush_method 和相关的 innodb_io_capacity 参数配置,InnoDB 可以管理脏页何时被写入磁盘。
监控和维护
性能监控:使用 SHOW ENGINE INNODB STATUS SQL 语句或者性能模式(Performance Schema)获取 Buffer Pool 的状态信息。
页面压缩: 对于压缩表,InnoDB Buffer Pool 会在需要时自动解压和重新压缩页。
内存管理: InnoDB 缓冲池内存管理是内置的,MySQL 数据库管理员通常不需要手动管理内存分配。
配置 Buffer Pool 的大小和行为通常依赖于系统的工作负载、硬件资源和性能要求。为了优化性能,数据库管理员可能需要调整 Buffer Pool 相关配置参数,并监控其性能指标,通过试验找到最佳的配置。
缓冲池的作用
数据页缓存:当一个数据页(如:表的数据和索引)被加载到 Buffer Pool 时,InnoDB 可以直接从内存中访问它,而不是从磁盘中读取。这大大加速了数据检索。
脏页的刷新:当事务修改了缓存的数据页时,这些数据页被标记为"脏页"。这些脏页最终会在一定时机(如:事务提交、检查点创建或者背景写入任务触发时)被刷新回磁盘。
Change Buffer:对非唯一非聚簇索引的修改不会立即写入磁盘,而是缓存到 Buffer Pool 中的 Change Buffer 区域,当这一部分的数据页被加载到 Buffer Pool 时,变更才会被合并并应用到页上。
读写冲突减少:Buffer Pool 提供了一种机制来管理对数据的并发访问,包括锁和数据一致性。
内存分配与管理
大小设置:Buffer Pool 的大小由 innodb_buffer_pool_size 配置参数控制。建议将其设置为可用内存的大部分,但要为操作系统和其他服务器进程留出足够空间。
多个 Buffer Pool 实例:可以将 Buffer Pool 分割为多个独立的实例,通过 innodb_buffer_pool_instances 配置。这有助于减少争用并改善多核系统上的性能。
LRU 算法:Buffer Pool 使用最近最少使用(LRU)列表维护页面。最常访问的页位于列表的前端,而最少访问的页位于列表后端,从而可以在需要时逐出。
内存压缩,预读和刷新策略
内存压缩:InnoDB 可以缩放页来压缩数据,从而在Buffer Pool中存储更多的数据。
预读:InnoDB 使用预读机制自动或手动预加载可能即将被查询的数据页。
脏页刷新策略:通过 innodb_flush_method 和相关的 innodb_io_capacity 参数配置,InnoDB 可以管理脏页何时被写入磁盘。
监控和维护
性能监控:使用 SHOW ENGINE INNODB STATUS SQL 语句或者性能模式(Performance Schema)获取 Buffer Pool 的状态信息。
页面压缩: 对于压缩表,InnoDB Buffer Pool 会在需要时自动解压和重新压缩页。
内存管理: InnoDB 缓冲池内存管理是内置的,MySQL 数据库管理员通常不需要手动管理内存分配。
配置 Buffer Pool 的大小和行为通常依赖于系统的工作负载、硬件资源和性能要求。为了优化性能,数据库管理员可能需要调整 Buffer Pool 相关配置参数,并监控其性能指标,通过试验找到最佳的配置。
Mysql索引优化
mysql索引优化案例与分析理解(like、order等常见查询)
KEY `idx_name_age_position` (`name`,`age`,`position`) USING BTREE
KEY `idx_name_age_position` (`name`,`age`,`position`) USING BTREE
name>'xxx'结果集大不走索引,结果集少走索引
- 可使用辅助索引优化,只查询索引列
理解:name>'xxx',mysql根据ASCLL码估算,排在前面的结果集会很大(避免回表次数,故不走索引),排到后面的认为结果集会很小(回表少,故走索引)
- name>xxx在数据量大的时候,name>a就更大了,所以不走索引,而name>zzz 的结果集一般会小(毕竟z在字母顺序最后面),所以走索引
- 通过实验证明确实如此,而且就算是表中数据量小,name>a也是ALL,name>zzz因为z在字母顺序最后面mysql估计结果集很少会选择走索引
这里我觉得是可以继续优化的,比如应该是表数据小的时候a、z都不走索引,表数据大的时候a-ALL,z-range,而不是z不管数据多不多都走索引
in和or在表数据量比较大的情况会走索引,在表记录不多的情况下会选择全表扫描
理解:in跟or,mysql觉得就算表数据量大结果也不会很多,回表次数自然少,所以表数据量大走索引,表数据量少不走索引
跟上面的范围查找相反居然,按理是不是应该一样的,毕竟记录多回表次数多(我复制过一个表当查询到的数据多,in和or也会走索引)。可以这么理解,name>xx比name in (...)跟or比较,name>xxx在数据量大的时候,结果集一般会更大,所以回表次数就多,此时不走索引比走索引可能更好,然后in跟or就算数据量大的时候,结果集也不会很大,然后回表次数也就不会很大,而且我可以直接通过全部索引快速定位找到,自然是走索引好些
但是在数据量少的时候都不走索引,这就有点儿奇怪,只能认为它跟>或者like采用判断方式不同了,它觉得数据少就都没必要走索引了,可能这个开发者认为回表代价大。其实就是在数据量少的情况下,在索引检索跟回表直接平衡选择中,like跟>选择了回表,in跟or选择了不回表
跟上面的范围查找相反居然,按理是不是应该一样的,毕竟记录多回表次数多(我复制过一个表当查询到的数据多,in和or也会走索引)。可以这么理解,name>xx比name in (...)跟or比较,name>xxx在数据量大的时候,结果集一般会更大,所以回表次数就多,此时不走索引比走索引可能更好,然后in跟or就算数据量大的时候,结果集也不会很大,然后回表次数也就不会很大,而且我可以直接通过全部索引快速定位找到,自然是走索引好些
但是在数据量少的时候都不走索引,这就有点儿奇怪,只能认为它跟>或者like采用判断方式不同了,它觉得数据少就都没必要走索引了,可能这个开发者认为回表代价大。其实就是在数据量少的情况下,在索引检索跟回表直接平衡选择中,like跟>选择了回表,in跟or选择了不回表
like xx% 相当于>'xxx'的优化版本
理解:like xx%,在索引树上其实跟>'xxx'一样的道理,所以是否采用索引判断也一样,但它在表数据量小的时候都走索引
- like xx%在数据量大的时候,like aa%就更大了,所以不走索引,而like zz% 的结果集一般会小(毕竟z在字母顺序最后面),所以走索引
- 表数据量小,结果集肯定小,所以回表次数少,故like xx%都是range
索引下推
什么是索引下推:MySQL 5.6引入了索引下推优化,可以在索引遍历过程中,对索引中包含的所有字段先做判断,过滤掉不符合条件的记录之后再回表,可以有效的减少回表次数。使用了索引下推优化后,上面那个查询在联合索引里匹配到名字是 'LiLei' 开头的索引之后,同时还会在索引结构里过滤age和position这两个字段,拿着过滤完剩下的索引对应的主键id再回表查整行数据
like跟in、or都会选择索引下推,而范围查找>并不会采用索引下推,这也是mysql可以优化的点
Order by与Group by
MySQL支持两种方式的排序filesort和index,Using index是指MySQL扫描索引本身完成排序。index效率高,filesort效率低
order by满足两种情况会使用Using index。
1) order by语句使用索引最左前列。
2) 使用where子句与order by子句条件列组合满足索引最左前列
1) order by语句使用索引最左前列。
2) 使用where子句与order by子句条件列组合满足索引最左前列
尽量在索引列上完成排序,遵循索引建立(索引创建的顺序)时的最左前缀法则
如果order by的条件不在索引列上,就会产生Using filesort
能用覆盖索引尽量用覆盖索引
group by与order by很类似,其实质是先排序后分组,遵照索引创建顺序的最左前缀法则。对于group by的优化如果不需要排序的可以加上order by null禁止排序。注意,where高于having,能写在where中的限定条件就不要去having限定了
Using filesort文件排序原理
filesort文件排序方式
- 单路排序:是一次性取出满足条件行的所有字段,然后在sort buffer(每个客户端建立连接后server端都会为其开辟一块内存缓存池)中进行排序;用trace工具可以看到sort_mode信息里显示< sort_key, additional_fields >或者< sort_key, packed_additional_fields >
- 双路排序(又叫回表排序模式):是首先根据相应的条件取出相应的排序字段和可以直接定位行数据的行 ID,然后在 sort buffer 中进行排序,排序完后需要再次取回其它需要的字段;用trace工具可以看到sort_mode信息里显示< sort_key, rowid >
MySQL 通过比较系统变量 max_length_for_sort_data(默认1024字节) 的大小和需要查询的字段总大小来判断使用哪种排序模式。
- 如果 字段的总长度小于max_length_for_sort_data ,那么使用 单路排序模式;
- 如果 字段的总长度大于max_length_for_sort_data ,那么使用 双路排序模式。
单路排序首先会在索引树中找到满足条件的id,然后回表获取全部数据,接着把数据放入sort buffer
双路排序在索引树中找到满足条件的数据后放入sort buffer中,排序完成后再回表数据
双路排序在索引树中找到满足条件的数据后放入sort buffer中,排序完成后再回表数据
不管是单路排序还是双路排序,当sort_buffer内存不能一次性放下当前需要排序的所有记录的时候,都会降级采用磁盘排序。那怎么理解这个磁盘排序呢?可以这么理解:分批次的拉取需要排序的记录,每次排完后放入磁盘中,继续下一批记录排序,当全部排序后后再整理起来(可以理解为把每个批次的最大最小都保存在sort_buffer中,便于后续排序所有批次)
注意,如果全部使用sort_buffer内存排序一般情况下效率会高于磁盘文件排序,但不能因为这个就随便增大sort_buffer(默认1M),mysql很多参数设置都是做过优化的,不要轻易调整。
索引设计原则
代码先行,索引后上:一般应该等到主体业务功能开发完毕,把涉及到该表相关sql都要拿出来分析之后再建立索引
联合索引尽量覆盖条件:比如可以设计一个或者两三个联合索引(尽量少建单值索引),让每一个联合索引都尽量去包含sql语句里的where、order by、group by的字段,还要确保这些联合索引的字段顺序尽量满足sql查询的最左前缀原则
不要在小基数字段上建立索引:比如性别字段上建立索引,根本没法进行快速的二分查找(相当于索引树最底下只有两块),那用索引就没有太大的意义了
长字符串我们可以采用前缀索引:尽量对字段类型较小的列设计索引,因为字段类型较小的话,占用磁盘空间也会比较小,此时你在搜索的时候性能也会比较好一点。
where与order by冲突时优先where:在where和order by出现索引设计冲突时,到底是针对where去设计索引,还是针对order by设计索引?到底是让where去用上索引,还是让order by用上索引?一般这种时候往往都是让where条件去使用索引来快速筛选出来一部分指定的数据,接着再进行排序。因为大多数情况基于索引进行where筛选往往可以最快速度筛选出你要的少部分数据,然后做排序的成本可能会小很多
基于慢sql查询做优化:可以根据监控后台的一些慢sql,针对这些慢sql查询做特定的索引优化。
- slow_query_log:是否开启慢查询日志,1表示开启,0表示关闭
- long_query_time:慢查询阈值,当查询时间多于设定的阈值时,记录日志
当然表设计也是很重要的,比如当你查询一个字段的时候需要关联表甚至需要复杂的刷选,这个时候往往在表中新增一个字段更好,放在业务上维护字段
mysql索引优化案例与分析理解(分页、join、count与类型选择)
KEY `idx_name_age_position` (`name`,`age`,`position`) USING BTREE
KEY `idx_name_age_position` (`name`,`age`,`position`) USING BTREE
分页查询检索方式
表示从表 employees 中取出从 10001 行开始的 10 行记录。看似只查询了 10 条记录,实际这条 SQL 是先读取 10010 条记录,然后抛弃前 10000 条记录,得到 10 条我们想要的数据。因此要查询一张大表比较靠后的数据,执行效率是非常低的。
那这种该怎么优化呢?
- select * from employees limit 10000,10;
- select * from employees ORDER BY name limit 90000,5;
那这种该怎么优化呢?
- 如果是主键有序可以修改成id范围查询:select * from employees where id > 90000 limit 5; 但是实际场景基本不适用
- 如果非主键条件可以采用子查询(让排序时返回的字段尽可能少):select * from employees e inner join (select id from employees order by name limit 90000,5) ed on e.id = ed.id;
- 业务限制:一般这种场景很少的,谁会没事查那么多页,通过业务处理,比如添加条件、只提供近一年的数据查看等
Join关联查询优化
说明:t1、t2两张表结构一模一样,t1插入数据1万条,t2插入数据100条。辅助索引为a
说明:t1、t2两张表结构一模一样,t1插入数据1万条,t2插入数据100条。辅助索引为a
mysql的表关联常见有两种算法
- 优化器一般会优先选择小表做驱动表,用where条件过滤完驱动表,然后再跟被驱动表做关联查询。所以使用 inner join 时,排在前面的表并不一定就是驱动表
- 当使用left join时,左表是驱动表,右表是被驱动表,当使用right join时,右表时驱动表,左表是被驱动表,当使用join时,mysql会选择数据量比较小的表作为驱动表,大表作为被驱动表
- 使用了 NLJ算法。一般 join 语句中,如果执行计划 Extra 中未出现 Using join buffer 则表示使用的 join 算法是 NLJ
嵌套循环连接 Nested-Loop Join(NLJ) 算法
一次一行循环地从第一张表(称为驱动表)中读取行,在这行数据中取到关联字段,根据关联字段在另一张表(被驱动表)里取出满足条件的行,然后取出两张表的结果合集
整个过程会读取 t2 表的所有数据(扫描100行),然后遍历这每行数据中字段 a 的值,根据 t2 表中 a 的值索引扫描 t1 表中的对应行(扫描100次 t1 表的索引,因为是在索引树直接定位到a,所以一次扫描一次,共100次)。因此整个过程扫描了100(t1)+100(t2)=200 行。如果是t1为驱动表,则需要扫描10000(t1)+10000(t2)=20000次
- select * from t1 inner join t2 on t1.a= t2.a;-- t2数据少为驱动表
基于块的嵌套循环连接 Block Nested-Loop Join(BNL)算法
把驱动表的数据读入到 join_buffer 中,然后扫描被驱动表,把被驱动表每一行取出来跟 join_buffer 中的数据做对比。
Extra 中 的Using join buffer (Block Nested Loop)说明该关联查询使用的是 BNL 算法。
整个过程对表 t1 和 t2 都做了一次全表扫描,因此扫描的总行数为10000(表 t1 的数据总量) + 100(表 t2 的数据总量) = 10100。并且 join_buffer 里的数据是无序的,因此对表 t1 中的每一行,都要做 100 次判断,所以内存中的判断次数是 100 * 10000= 100 万次。
- select * from t1 inner join t2 on t1.b= t2.b;-- b没有索引 t2数据少为驱动表
join_buffer 放不下怎么办呢?·
join_buffer 的大小是由参数 join_buffer_size 设定的,默认值是 256k。如果放不下表 t2 的所有数据话,策略很简单,就是分段放。
join_buffer 的大小是由参数 join_buffer_size 设定的,默认值是 256k。如果放不下表 t2 的所有数据话,策略很简单,就是分段放。
被驱动表的关联字段没索引为什么要选择使用 BNL 算法而不使用 Nested-Loop Join 呢?
如果上面第二条sql使用 Nested-Loop Join,那么扫描行数为 100 * 10000 = 100万次,这个是磁盘扫描。很显然,用BNL磁盘扫描次数少很多,相比于磁盘扫描,BNL的内存计算会快得多。因此MySQL对于被驱动表的关联字段没索引的关联查询,一般都会使用 BNL 算法。如果有索引一般选择 NLJ 算法,有索引的情况下 NLJ 算法比 BNL算法性能更高
- 在没有索引前提下,NLJ采用的是:驱动表取一行,然后被驱动表一行一行的取出来比较;
- BNL采用的是:先一次性将驱动表取出来放到buffer中,然后一行一行的取被驱动表比较
对于关联sql的优化
关联字段加索引,让mysql做join操作时尽量选择NLJ算法,驱动表因为需要全部查询出来,所以过滤的条件也尽量要走索引,避免全表扫描,总之,能走索引的过滤条件尽量都走索引
小表驱动大表,写多表连接sql时如果明确知道哪张表是小表选择好连接驱动方式,省去mysql优化器自己判断的时间
小表驱动大表,写多表连接sql时如果明确知道哪张表是小表选择好连接驱动方式,省去mysql优化器自己判断的时间
count(*)查询优化
count(*)、count(1)、count(字段)、count(主键 id)
只有count(字段)不会统计null值
只有count(字段)不会统计null值
四个sql的执行计划一样,说明这四个sql执行效率应该差不多,因为在新版本中其实都优化成了count(*)
字段有索引:count(*)≈count(1)>count(字段)>count(主键 id) //字段有索引,count(字段)统计走二级索引,二级索引存储数据比主键索引少,所以count(字段)>count(主键 id)
字段无索引:count(*)≈count(1)>count(主键 id)>count(字段) //字段没有索引count(字段)统计走不了索引,count(主键 id)还可以走主键索引,所以count(主键 id)>count(字段)
字段有索引: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版本才优化)。
常见优化方法
查询mysql自己维护的总行数
- 对于myisam存储引擎的表做不带where条件的count查询性能是很高的,因为myisam存储引擎的表的总行数会被mysql存储在磁盘上,查询不需要计算
- 对于innodb存储引擎的表mysql不会存储表的总记录行数(因为有MVCC机制),查询count需要实时计算
show table status:如果只需要知道表总行数的估计值可以用如下sql查询,性能很高
将总数维护到Redis里:插入或删除表数据行的时候同时维护redis里的表总行数key的计数值(用incr或decr命令),但是这种方式可能不准,很难保证表操作和redis操作的事务一致性
增加数据库计数表:插入或删除表数据行的时候同时维护计数表,让他们在同一个事务里操作
MySQL数据类型选择
在MySQL中,选择正确的数据类型,对于性能至关重要。一般应该遵循下面两步:
(1)确定合适的类型:数字、字符串、时间、二进制;
(2)确定具体的类型:有无符号、取值范围、变长定长等。
在MySQL数据类型设置方面,尽量用更小的数据类型,因为它们通常有更好的性能,花费更少的硬件资源。并且,尽量把字段定义为NOT NULL,避免使用NULL
mysql哪些配置会导致索引选择错误
在MySQL中,索引选择错误通常是由以下几个方面的配置引起的:
确保在MySQL环境中进行索引选择和查询优化时,考虑到这些方面,并对相关配置进行审查和调整,可以帮助避免索引选择错误。
- 统计信息不准确或过期: MySQL使用统计信息来决定何时使用索引以及如何进行查询优化。如果统计信息不准确或过期,MySQL可能会做出错误的执行计划选择。
- MySQL参数配置: MySQL有各种配置参数,包括用于查询优化的参数,例如 optimizer_switch 和 optimizer_trace。错误的参数设置可能会导致MySQL做出不合适的索引选择。
- 索引类型和属性: 不正确的索引类型或属性设置(例如过多或过少的索引、索引列顺序不当、索引列数据类型不匹配等)可能导致MySQL选择错误的索引。
- 存储引擎选择: 不同的存储引擎对索引的处理方式有所不同。因此,选择不合适的存储引擎可能导致索引选择错误。
- 查询SQL写法: 错误的查询写法(例如使用通配符开头的搜索、多余的join等)可能会导致MySQL难以正确选择索引。
确保在MySQL环境中进行索引选择和查询优化时,考虑到这些方面,并对相关配置进行审查和调整,可以帮助避免索引选择错误。
SQL底层执行原理
MySQL的内部组件结构
Server 层和存储(Store)引擎层
Server层:主要包括连接器、查询缓存、分析器、优化器、执行器等,涵盖 MySQL 的大多数核心服务功能,以及所有的内置函数(如日期、时间、数学和加密函数等),所有跨存储引擎的功能都在这一层实现,比如存储过程、触发器、视图等
Store层:存储引擎层负责数据的存储和提取。其架构模式是插件式的,支持 InnoDB、MyISAM、Memory 等多个存储引擎。现在最常用的存储引擎是 InnoDB,它从 MySQL 5.5.5 版本开始成为了默认存储引擎。也就是说如果我们在create table时不指定表的存储引擎类型,默认会给你设置存储引擎为InnoDB
连接器
向mysql发起通信都必须先跟Server端建立通信连接,而建立连接的工作就是由连接器完成的。
连接命令中的 mysql 是客户端工具,用来跟服务端建立连接。在完成经典的 TCP 握手后,连接器就要开始认证你的身份,这个时候用的就是你输入的用户名和密码。
这就意味着,一个用户成功建立连接后,即使你用管理员账号对这个用户的权限做了修改,也不会影响已经存在连接的权限。修改完成后,只有再新建的连接才会使用新的权限设置。用户的权限表在系统表空间的mysql的user表中。
- 1、如果用户名或密码不对,你就会收到一个"Access denied for user"的错误,然后客户端程序结束执行。
- 2、如果用户名密码认证通过,连接器会到权限表里面查出你拥有的权限。之后,这个连接里面的权限判断逻辑,都将依赖于此时读到的权限(缓存起来)
这就意味着,一个用户成功建立连接后,即使你用管理员账号对这个用户的权限做了修改,也不会影响已经存在连接的权限。修改完成后,只有再新建的连接才会使用新的权限设置。用户的权限表在系统表空间的mysql的user表中。
连接完成后,如果你没有后续的动作,这个连接就处于空闲状态,你可以在 show processlist 命令中看到它,其中的 Command 列显示为“Sleep”的这一行,就表示现在系统里面有一个空闲连接,关闭连接 kill <id>
客户端如果长时间不发送command到Server端,连接器就会自动将它断开。这个时间是由参数 wait_timeout 控制的,默认值是 8 小时。
客户端如果长时间不发送command到Server端,连接器就会自动将它断开。这个时间是由参数 wait_timeout 控制的,默认值是 8 小时。
在实际中都会将连接放入连接池中,保持长连接,但是长连接有些时候会导致 MySQL 占用内存涨得特别快,这是因为 MySQL 在执行过程中临时使用的内存是管理在连接对象里面的(每次连接成功后都会开辟一些空间用来缓存各种信息,比如权限等)。这些资源会在连接断开的时候才释放。所以如果长连接累积下来,可能导致内存占用太大,被系统强行杀掉(OOM),从现象看就是 MySQL 异常重启了
怎么解决长连接占用内存太大这类问题呢?
1、定期断开长连接。使用一段时间,或者程序里面判断执行过一个占用内存的大查询后,断开连接,之后要查询再重连。
2、如果你用的是 MySQL 5.7 或更新版本,可以在每次执行一个比较大的操作后,通过执行 mysql_reset_connection 来重新初始化连接资源。这个过程不需要重连和重新做权限验证,但是会将连接恢复到刚刚创建完时的状态。
怎么解决长连接占用内存太大这类问题呢?
1、定期断开长连接。使用一段时间,或者程序里面判断执行过一个占用内存的大查询后,断开连接,之后要查询再重连。
2、如果你用的是 MySQL 5.7 或更新版本,可以在每次执行一个比较大的操作后,通过执行 mysql_reset_connection 来重新初始化连接资源。这个过程不需要重连和重新做权限验证,但是会将连接恢复到刚刚创建完时的状态。
查询缓存
连接建立完成后,你就可以执行 select 语句了。执行逻辑就会来到第二步:查询缓存。
MySQL 拿到一个查询请求后,会先到查询缓存看看,之前是不是执行过这条语句。之前执行过的语句及其结果可能会以 key-value 对的形式,被直接缓存在内存中。key 是查询的语句,value 是查询的结果。如果你的查询能够直接在这个缓存中找到 key,那么这个 value 就会被直接返回给客户端。
如果语句不在查询缓存中,就会继续后面的执行阶段。执行完成后,执行结果会被存入查询缓存中。你可以看到,如果查询命中缓存,MySQL 不需要执行后面的复杂操作,就可以直接返回结果,这个效率会很高。
如果语句不在查询缓存中,就会继续后面的执行阶段。执行完成后,执行结果会被存入查询缓存中。你可以看到,如果查询命中缓存,MySQL 不需要执行后面的复杂操作,就可以直接返回结果,这个效率会很高。
大多数情况查询缓存就是个鸡肋,为什么呢?
因为查询缓存往往弊大于利。查询缓存的失效非常频繁,只要有对一个表的更新,这个表上所有的查询缓存都会被清空。因此很可能你费劲地把结果存起来,还没使用呢,就被一个更新全清空了。对于更新压力大的数据库来说,查询缓存的命中率会非常低。
一般建议大家在静态表里使用查询缓存,什么叫静态表呢?就是一般我们极少更新的表。比如,一个系统配置表、字典表,那这张表上的查询才适合使用查询缓存。好在 MySQL 也提供了这种“按需使用”的方式。你可以将my.cnf参数 query_cache_type 设置成 DEMAND。
mysql8.0已经移除了查询缓存功能
因为查询缓存往往弊大于利。查询缓存的失效非常频繁,只要有对一个表的更新,这个表上所有的查询缓存都会被清空。因此很可能你费劲地把结果存起来,还没使用呢,就被一个更新全清空了。对于更新压力大的数据库来说,查询缓存的命中率会非常低。
一般建议大家在静态表里使用查询缓存,什么叫静态表呢?就是一般我们极少更新的表。比如,一个系统配置表、字典表,那这张表上的查询才适合使用查询缓存。好在 MySQL 也提供了这种“按需使用”的方式。你可以将my.cnf参数 query_cache_type 设置成 DEMAND。
mysql8.0已经移除了查询缓存功能
分析器
如果没有命中查询缓存,就要开始真正执行语句了。首先,MySQL 需要知道你要做什么,因此需要对 SQL 语句做解析。
词法分析:分析器先会做“词法分析”。你输入的是由多个字符串和空格组成的一条 SQL 语句,MySQL 需要识别出里面的字符串分别是什么,代表什么。
语法分析:根据词法分析的结果,语法分析器会根据语法规则,判断你输入的这个 SQL 语句是否满足 MySQL 语法
语法分析:根据词法分析的结果,语法分析器会根据语法规则,判断你输入的这个 SQL 语句是否满足 MySQL 语法
优化器
经过了分析器,MySQL 就知道你要做什么了。在开始执行之前,还要先经过优化器的处理
优化器是在表里面有多个索引的时候,决定使用哪个索引;或者在一个语句有多表关联(join)的时候,决定各个表的连接顺序
执行器
优化后就真正调用存储引擎接口开始查询语句
开始执行的时候,要先判断一下你对这个表 T 有没有执行查询的权限,如果没有,就会返回没有权限的错误,如下所示 (在工程实现上,如果命中查询缓存,会在查询缓存返回结果的时候,做权限验证。查询也会在优化器之前调用 precheck 验证权限)
执行步骤:
- 调用 InnoDB 引擎接口取这个表的第一行,判断是否满足查询条件,如果不是则跳过,如果是则将这行存在结果集中;
- 调用引擎接口取“下一行”,重复相同的判断逻辑,直到取到这个表的最后一行。
- 执行器将上述遍历过程中所有满足条件的行组成的记录集作为结果集返回给客户端
问题整理
MyISAM和InnoDB区别
- 是否支持行级锁 : MyISAM 只有表级锁(table-level locking),而InnoDB 支持行级锁(row-level locking)和表级锁,默认为行级锁。
- 是否支持事务和崩溃后的安全恢复: MyISAM 强调的是性能,每次查询具有原子性,其执行速度比InnoDB类型更快,但是不提供事务支持。但是InnoDB 提供事务支持事务,外部键等高级数据库功能。 具有事务(commit)、回滚(rollback)和崩溃修复能力(crash recovery capabilities)的事务安全(transaction-safe (ACID compliant))型表。
- 是否支持外键: MyISAM不支持,而InnoDB支持。
- 是否支持MVCC :仅 InnoDB 支持。应对高并发事务, MVCC比单纯的加锁更高效;MVCC只在 READ COMMITTED 和 REPEATABLE READ 两个隔离级别下工作;MVCC可以使用 乐观(optimistic)锁 和 悲观(pessimistic)锁来实现;各数据库中MVCC实现并不统一
- 存储结构不同:MyISAM索引文件和数据文件是分离的,是非聚集索引存储,数据存在的结构也是这样的
索引
MySQL索引使用的数据结构主要有B+Tree索引 和 哈希索引 。对于哈希索引来说,底层的数据结构就是哈希表,因此在绝大多数需求为单条记录查询的时候,可以选择哈希索引,查询性能最快;其余大部分场景,建议选择BTree索引。
MySQL的BTree索引使用的是B树中的B+Tree,但对于主要的两种存储引擎的实现方式是不同的。
- MyISAM: B+Tree叶节点的data域存放的是数据记录的地址。在索引检索的时候,首先按照B+Tree搜索算法搜索索引,如果指定的Key存在,则取出其 data 域的值,然后以 data 域的值为地址读取相应的数据记录。这被称为“非聚簇索引”。
- InnoDB: 其数据文件本身就是索引文件。相比MyISAM,索引文件和数据文件是分离的,其表数据文件本身就是按B+Tree组织的一个索引结构,树的叶节点data域保存了完整的数据记录。这个索引的key是数据表的主键,因此InnoDB表数据文件本身就是主索引。这被称为“聚簇索引(或聚集索引)”。而其余的索引都作为辅助索引,辅助索引的data域存储相应记录主键的值而不是地址,这也是和MyISAM不同的地方。在根据主索引搜索时,直接找到key所在的节点即可取出数据;在根据辅助索引查找时,则需要先取出主键的值,再走一遍主索引。 因此,在设计表的时候,不建议使用过长的字段作为主键,也不建议使用非单调的字段作为主键,这样会造成主索引频繁分裂
查询缓存的使用
执行查询语句的时候,会先查询缓存。不过,MySQL 8.0 版本后移除,因为这个功能不太实用
什么是事务?
事务是逻辑上的一组操作,要么都执行,要么都不执行
事物的四大特性(ACID)
原子性(Atomicity): 事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用;
一致性(Consistency): 执行事务前后,数据保持一致,多个事务对同一个数据读取的结果是相同的;
隔离性(Isolation): 并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的;
持久性(Durability): 一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。
并发事务带来哪些问题?
脏读(Dirty read): 当一个事务正在访问数据并且对数据进行了修改,而这种修改还没有提交到数据库中,这时另外一个事务也访问了这个数据,然后使用了这个数据。因为这个数据是还没有提交的数据,那么另外一个事务读到的这个数据是“脏数据”,依据“脏数据”所做的操作可能是不正确的。
丢失修改(Lost to modify): 指在一个事务读取一个数据时,另外一个事务也访问了该数据,那么在第一个事务中修改了这个数据后,第二个事务也修改了这个数据。这样第一个事务内的修改结果就被丢失,因此称为丢失修改。 例如:事务1读取某表中的数据A=20,事务2也读取A=20,事务1修改A=A-1,事务2也修改A=A-1,最终结果A=19,事务1的修改被丢失。
不可重复读(Unrepeatableread): 指在一个事务内多次读同一数据。在这个事务还没有结束时,另一个事务也访问该数据。那么,在第一个事务中的两次读数据之间,由于第二个事务的修改导致第一个事务两次读取的数据可能不太一样。这就发生了在一个事务内两次读到的数据是不一样的情况,因此称为不可重复读。
幻读(Phantom read): 幻读与不可重复读类似。它发生在一个事务(T1)读取了几行数据,接着另一个并发事务(T2)插入了一些数据时。在随后的查询中,第一个事务(T1)就会发现多了一些原本不存在的记录,就好像发生了幻觉一样,所以称为幻读
丢失修改(Lost to modify): 指在一个事务读取一个数据时,另外一个事务也访问了该数据,那么在第一个事务中修改了这个数据后,第二个事务也修改了这个数据。这样第一个事务内的修改结果就被丢失,因此称为丢失修改。 例如:事务1读取某表中的数据A=20,事务2也读取A=20,事务1修改A=A-1,事务2也修改A=A-1,最终结果A=19,事务1的修改被丢失。
不可重复读(Unrepeatableread): 指在一个事务内多次读同一数据。在这个事务还没有结束时,另一个事务也访问该数据。那么,在第一个事务中的两次读数据之间,由于第二个事务的修改导致第一个事务两次读取的数据可能不太一样。这就发生了在一个事务内两次读到的数据是不一样的情况,因此称为不可重复读。
幻读(Phantom read): 幻读与不可重复读类似。它发生在一个事务(T1)读取了几行数据,接着另一个并发事务(T2)插入了一些数据时。在随后的查询中,第一个事务(T1)就会发现多了一些原本不存在的记录,就好像发生了幻觉一样,所以称为幻读
事务隔离级别有哪些?MySQL的默认隔离级别是?
READ-UNCOMMITTED(读取未提交): 最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读。
READ-COMMITTED(读取已提交): 允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生。
REPEATABLE-READ(可重复读): 对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。
SERIALIZABLE(可串行化): 最高的隔离级别,完全服从ACID的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读
MySQL InnoDB 存储引擎的默认支持的隔离级别是 REPEATABLE-READ(可重读)
锁机制与InnoDB锁算法
它这个答案还不如我前面的,从各种角度锁分类
MyISAM和InnoDB存储引擎使用的锁:
表级锁和行级锁对比:
MyISAM和InnoDB存储引擎使用的锁:
- MyISAM采用表级锁(table-level locking)。
- InnoDB支持行级锁(row-level locking)和表级锁,默认为行级锁
表级锁和行级锁对比:
- 表级锁: MySQL中锁定 粒度最大 的一种锁,对当前操作的整张表加锁,实现简单,资源消耗也比较少,加锁快,不会出现死锁。其锁定粒度最大,触发锁冲突的概率最高,并发度最低,MyISAM和 InnoDB引擎都支持表级锁。
- 行级锁: MySQL中锁定 粒度最小 的一种锁,只针对当前操作的行进行加锁。 行级锁能大大减少数据库操作的冲突。其加锁粒度最小,并发度高,但加锁的开销也最大,加锁慢,会出现死锁
- Record lock:单个行记录上的锁
- Gap lock:间隙锁,锁定一个范围,不包括记录本身
- Next-key lock:临建锁,record+gap 锁定一个范围,包含记录本身
大表优化
限定数据的范围:务必禁止不带任何限制数据范围条件的查询语句。比如:我们当用户在查询订单历史的时候,我们可以控制在一个月的范围内
读/写分离:经典的数据库拆分方案,主库负责写,从库负责读;
垂直分区:按照业务拆分的方式称为垂直分片,又称为纵向拆分,它的核心理念是专库专用。 在拆分之前,一个数据库由多个数据表构成,每个表对应着不同的业务。而拆分之后,则是按照业务将表进行归类,分布到不同的数据库中,从而将压力分散至不同的数据库
水平分区:水平分片又称为横向拆分。 相对于垂直分片,它不再将数据根据业务逻辑分类,而是通过某个字段(或某几个字段),根据某种规则将数据分散至多个库或表中,每个分片仅包含数据的一部分。 例如:根据主键分片,偶数主键的记录放入0库(或表),奇数主键的记录放入1库(或表)
读/写分离:经典的数据库拆分方案,主库负责写,从库负责读;
垂直分区:按照业务拆分的方式称为垂直分片,又称为纵向拆分,它的核心理念是专库专用。 在拆分之前,一个数据库由多个数据表构成,每个表对应着不同的业务。而拆分之后,则是按照业务将表进行归类,分布到不同的数据库中,从而将压力分散至不同的数据库
水平分区:水平分片又称为横向拆分。 相对于垂直分片,它不再将数据根据业务逻辑分类,而是通过某个字段(或某几个字段),根据某种规则将数据分散至多个库或表中,每个分片仅包含数据的一部分。 例如:根据主键分片,偶数主键的记录放入0库(或表),奇数主键的记录放入1库(或表)
解释一下什么是池化设计思想。什么是数据库连接池?为什么需要数据库连接池?
池化设计应该不是一个新名词。我们常见的如java线程池、jdbc连接池、redis连接池等就是这类设计的代表实现。这种设计会初始预设资源,解决的问题就是抵消每次获取资源的消耗,如创建线程的开销,获取远程连接的开销等
分库分表之后,id 主键如何处理?
因为要是分成多个表之后,每个表都是从 1 开始累加,这样是不对的,我们需要一个全局唯一的 id 来支持
生成全局 id 有下面这几种方式:
UUID:不适合作为主键,因为太长了,并且无序不可读,查询效率低。比较适合用于生成唯一的名字的标示比如文件的名字。
数据库自增 id : 两台数据库分别设置不同步长,生成不重复ID的策略来实现高可用。这种方式生成的 id 有序,但是需要独立部署数据库实例,成本高,还会有性能瓶颈。
利用 redis 生成 id : 性能比较好,灵活方便,不依赖于数据库。但是,引入了新的组件造成系统更加复杂,可用性降低,编码更加复杂,增加了系统成本。
Twitter的snowflake算法 :Github 地址:https://github.com/twitter-archive/snowflake。
美团的Leaf分布式ID生成系统 :Leaf 是美团开源的分布式ID生成器,能保证全局唯一性、趋势递增、单调递增、信息安全,里面也提到了几种分布式方案的对比,但也需要依赖关系数据库、Zookeeper等中间件。
UUID:不适合作为主键,因为太长了,并且无序不可读,查询效率低。比较适合用于生成唯一的名字的标示比如文件的名字。
数据库自增 id : 两台数据库分别设置不同步长,生成不重复ID的策略来实现高可用。这种方式生成的 id 有序,但是需要独立部署数据库实例,成本高,还会有性能瓶颈。
利用 redis 生成 id : 性能比较好,灵活方便,不依赖于数据库。但是,引入了新的组件造成系统更加复杂,可用性降低,编码更加复杂,增加了系统成本。
Twitter的snowflake算法 :Github 地址:https://github.com/twitter-archive/snowflake。
美团的Leaf分布式ID生成系统 :Leaf 是美团开源的分布式ID生成器,能保证全局唯一性、趋势递增、单调递增、信息安全,里面也提到了几种分布式方案的对比,但也需要依赖关系数据库、Zookeeper等中间件。
一条SQL语句在MySQL中如何执行的
MySQL高性能优化规范建议
建表规约
【强制】表达是与否概念的字段,必须使用 is_xxx 的方式命名,数据类型是 unsigned tinyint(1 表示是,0 表示否)。
【强制】表名、字段名必须使用小写字母或数字,禁止出现数字开头,禁止两个下划线中间只出现数字。数据库字段名的修改代价很大,因为无法进行预发布,所以字段名称需要慎重考虑。
【强制】表名不使用复数名词
【强制】禁用保留字,如 desc、range、match、delayed 等,请参考 MySQL 官方保留字。
【强制】主键索引名为 pk_字段名;唯一索引名为 uk_字段名;普通索引名则为 idx_字段名
【强制】小数类型为 decimal,禁止使用 float 和 double
【强制】如果存储的字符串长度几乎相等,使用 char 定长字符串类型
【强制】varchar 是可变长字符串,不预先分配存储空间,长度不要超过 5000,如果存储长度大于此值,定义字段类型为 text,独立出来一张表,用主键来对应,避免影响其它字段索引效率。
【强制】表必备三字段:id, gmt_create, gmt_modified。
索引规约
【强制】业务上具有唯一特性的字段,即使是组合字段,也必须建成唯一索引。
【强制】超过三个表禁止 join。需要 join 的字段,数据类型保持绝对一致;多表关联查询时,保证被关联的字段需要有索引。
【强制】在 varchar 字段上建立索引时,必须指定索引长度,没必要对全字段建立索引,根据实际文本区分度决定索引长度。
【强制】页面搜索严禁左模糊或者全模糊,如果需要请走搜索引擎来解决。
SQL 语句
【强制】不要使用 count(列名)或 count(常量)来替代 count(*),count(*)是 SQL92 定义的标准统计行数的语法,跟数据库无关,跟 NULL 和非 NULL 无关。
【强制】count(distinct col) 计算该列除 NULL 之外的不重复行数,注意 count(distinct col1, col2) 如果其中一列全为 NULL,那么即使另一列有不同的值,也返回为 0。
【强制】当某一列的值全是 NULL 时,count(col)的返回结果为 0,但 sum(col)的返回结果为NULL,因此使用 sum()时需注意 NPE 问题。
正例:可以使用如下方式来避免 sum 的 NPE 问题:SELECT IFNULL(SUM(column), 0) FROM table;
正例:可以使用如下方式来避免 sum 的 NPE 问题:SELECT IFNULL(SUM(column), 0) FROM table;
【强制】使用 ISNULL()来判断是否为 NULL 值
【强制】代码中写分页查询逻辑时,若 count 为 0 应直接返回,避免执行后面的分页语句
【强制】不得使用外键与级联,一切外键概念必须在应用层解决
【强制】禁止使用存储过程,存储过程难以调试和扩展,更没有移植性
【强制】数据订正(特别是删除或修改记录操作)时,要先 select,避免出现误删除,确认无误才能执行更新语句。
【强制】对于数据库中表记录的查询和变更,只要涉及多个表,都需要在列名前加表的别名(或表名)进行限定。
ORM 映射
【强制】在表查询中,一律不要使用 * 作为查询的字段列表,需要哪些字段必须明确写明
【强制】POJO 类的布尔属性不能加 is,而数据库字段必须加 is_,要求在 resultMap 中进行字段与属性之间的映射。
【强制】不要用 resultClass 当返回参数,即使所有类属性名与数据库字段一一对应,也需要定义<resultMap>;反过来,每一个表也必然有一个<resultMap>与之对应。
【强制】sql.xml 配置参数使用:#{},#param# 不要使用${} 此种方式容易出现 SQL 注入
【强制】iBATIS 自带的 queryForList(String statementName,int start,int size)不推荐使用。说明:其实现方式是在数据库取到 statementName 对应的 SQL 语句的所有记录,再通过 subList 取start,size 的子集合
【强制】不允许直接拿 HashMap 与 Hashtable 作为查询结果集的输出
【强制】更新数据表记录时,必须同时更新记录对应的 gmt_modified 字段值为当前时间
一条SQL语句执行得很慢的原因有哪些?
针对偶尔很慢的情况
数据库在刷新脏页(flush)我也无奈啊
- redolog写满了:redo log 里的容量是有限的,如果数据库一直很忙,更新又很频繁,这个时候 redo log 很快就会被写满了,这个时候就没办法等到空闲的时候再把数据同步到磁盘的,只能暂停其他操作,全身心来把数据同步到磁盘中去的,而这个时候,就会导致我们平时正常的SQL语句突然执行的很慢,所以说,数据库在在同步数据到磁盘的时候,就有可能导致我们的SQL语句执行的很慢了。
- 内存不够用了:如果一次查询较多的数据,恰好碰到所查数据页不在内存中时,需要申请内存,而此时恰好内存不足的时候就需要淘汰一部分内存数据页,如果是干净页,就直接释放,如果恰好是脏页就需要刷脏页。
- MySQL 认为系统“空闲”的时候:这时系统没什么压力。
- MySQL 正常关闭的时候:这时候,MySQL 会把内存的脏页都 flush 到磁盘上,这样下次 MySQL 启动的时候,就可以直接从磁盘上读数据,启动速度会很快
拿不到锁我能怎么办
这个就比较容易想到了,我们要执行的这条语句,刚好这条语句涉及到的表,别人在用,并且加锁了,我们拿不到锁,只能慢慢等待别人释放锁了。或者,表没有加锁,但要使用到的某个一行被加锁了,这个时候,我也没办法啊
针对一直都这么慢的情况
扎心了,没用到索引
字段没有索引
字段有索引,但却没有用索引
函数操作导致没有用上索引
字段有索引,但却没有用索引
函数操作导致没有用上索引
呵呵,数据库自己选错索引了
采样的那一部分数据刚好基数很小,然后就误以为索引的基数很小。然后就呵呵,系统就不走 c 索引了,直接走全部扫描了
由于统计的失误,导致系统没有走索引,而是走了全表扫描
由于统计的失误,导致系统没有走索引,而是走了全表扫描
不过呢,我们有时候也可以通过强制走索引的方式来查询
书写高质量SQL的建议
1、查询SQL尽量不要使用select *,而是select具体字段。
2、如果知道查询结果只有一条或者只要最大/最小一条记录,建议用limit 1
3、应尽量避免在where子句中使用or来连接条件
使用where条件限定要查询的数据,避免返回多余的行
尽量避免在索引列上使用mysql的内置函数
应尽量避免在 where 子句中对字段进行表达式操作,这将导致系统放弃使用索引而进行全表扫
Inner join 、left join、right join,优先使用Inner join,如果是left join,左边表结果尽量小
应尽量避免在 where 子句中使用!=或<>操作符,否则将引擎放弃使用索引而进行全表扫描。
使用联合索引时,注意索引列的顺序,一般遵循最左匹配原则。
对查询进行优化,应考虑在 where 及 order by 涉及的列上建立索引,尽量避免全表扫描
如果插入数据过多,考虑批量插入
在适当的时候,使用覆盖索引
慎用distinct /dɪˈstɪŋkt/ 关键字
删除冗余和重复索引
如果数据量较大,优化你的修改/删除语句:避免同时修改或删除过多数据,因为会造成cpu利用率过高,从而影响别人对数据库的访问
where子句中考虑使用默认值代替null
不要有超过5个以上的表连接
索引不宜太多,一般5个以内
尽量使用数字型字段,若只含数值信息的字段尽量不要设计为字符型
索引不适合建在有大量重复数据的字段上,如性别这类型数据库字段
尽量避免向客户端返回过多数据量
为了提高group by 语句的效率,可以在执行到该语句前,把不需要的记录过滤掉
如何字段类型是字符串,where时一定用引号括起来,否则索引失效
使用explain 分析你SQL的计划
索引失效情况
1) 没有查询条件,或者查询条件没有建立索引
2) 索引本身失效,比如没有最左匹配
3) 查询条件或者查询列中使用函数在索引列上
4) 对小表查询,mysql觉得全表扫描更快时
5) mysql通过计算后觉得走索引反而会更慢,如回表多,表数据少,猜测结果集多(回表多)
6) 隐式转换导致索引失效.比如数字类型采用varchar类型比较,age='18'
7) 但是像in、or、<>这些是未必不走索引的,只能说大部分可能不会走索引
redis
redis核心数据结构与互联网应用场景
Redis的单线程和高性能
Redis是单线程吗?
Redis 的单线程主要是指 Redis 的网络 IO 和键值对读写是由一个线程来完成的,这也是 Redis 对外提供键值存储服务的主要流程。但 Redis 的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的
Redis 单线程为什么还能这么快?
因为它所有的数据都在内存中,所有的运算都是内存级别的运算,而且单线程避免了多线程的切换性能损耗问题。正因为 Redis 是单线程,所以要小心使用 Redis 指令,对于那些耗时的指令(比如keys),一定要谨慎使用,一不小心就可能会导致 Redis 卡顿。 当然还有一个原因就是因为redis的多路复用IO模型。redis官方说明是支持每秒10w级别的命令处理
Redis的IO多路复用:redis利用epoll来实现IO多路复用,将连接信息和事件放到队列中,依次放到文件事件分派器(根据事件类型分派到不同处理器中处理),事件分派器将事件分发给事件处理器。这就是Redis 单线程如何处理那么多的并发客户端连接的原因。这个结合Netty的线程模型看容易理解
redis核心数据结构与互联网应用场景
string结构
常用操作命令介绍
SET key value //存入字符串键值对,如果 key 已经持有其他值, SET 就覆写旧值(过期时间也会清除),无视类型。
EX second :设置键的过期时间为 second 秒。 SET key value EX second 效果等同于 SETEX key second value 。
PX millisecond :设置键的过期时间为 millisecond 毫秒。 SET key value PX millisecond 效果等同于 PSETEX key millisecond value 。
NX :只在键不存在时,才对键进行设置操作。 SET key value NX 效果等同于 SETNX key value 。
XX :只在键已经存在时,才对键进行设置操作。
返回值:从 Redis 2.6.12 版本开始, SET 在设置操作成功完成时,才返回 OK 。
EX second :设置键的过期时间为 second 秒。 SET key value EX second 效果等同于 SETEX key second value 。
PX millisecond :设置键的过期时间为 millisecond 毫秒。 SET key value PX millisecond 效果等同于 PSETEX key millisecond value 。
NX :只在键不存在时,才对键进行设置操作。 SET key value NX 效果等同于 SETNX key value 。
XX :只在键已经存在时,才对键进行设置操作。
返回值:从 Redis 2.6.12 版本开始, SET 在设置操作成功完成时,才返回 OK 。
MSET key value [key value ...] //批量存储字符串键值对,MSET 是一个原子性(atomic)操作
如果某个给定 key 已经存在,那么 MSET 会用新值覆盖原来的旧值,如果这不是你所希望的效果,请考虑使用 MSETNX 命令:它只会在所有给定 key 都不存在的情况下进行设置操作。
总是返回 OK (因为 MSET 不可能失败)
如果某个给定 key 已经存在,那么 MSET 会用新值覆盖原来的旧值,如果这不是你所希望的效果,请考虑使用 MSETNX 命令:它只会在所有给定 key 都不存在的情况下进行设置操作。
总是返回 OK (因为 MSET 不可能失败)
SETNX key value //若给定的 key 已经存在,则 SETNX 不做任何动作。SETNX 是『SET if Not eXists』(如果不存在,则 SET)的简写
设置成功,返回 1 。
设置失败,返回 0
设置成功,返回 1 。
设置失败,返回 0
GET key //获取一个字符串键值,如果 key 不存在那么返回特殊值 nil,假如 key 储存的值不是字符串类型,返回一个错误,因为 GET 只能用于处理字符串值。
MGET key [key ...] //批量获取字符串键值,如果给定的 key 里面,有某个 key 不存在,那么这个 key 返回特殊值 nil 。因此,该命令永不失败。
两个针对key的命令:
- DEL key [key ...] //删除一个键,不存在的 key 会被忽略。
- EXPIRE key seconds //为给定 key 设置生存时间,当 key 过期时(生存时间为 0 ),它会被自动删除。
原子加减:
- INCR key //将key中储存的数字值加1,如果 key 不存在,那么 key 的值会先被初始化为 0 ,然后再执行 INCR 操作。返回值:执行 INCR 命令之后 key 的值
- DECR key //将key中储存的数字值减1,如果 key 不存在,那么 key 的值会先被初始化为 0 ,然后再执行 DECR 操作。返回值:执行 DECR 命令之后 key 的值。
- INCRBY key increment //将key所储存的值加上increment,如果 key 不存在,那么 key 的值会先被初始化为 0 ,然后再执行 INCRBY 命令。返回值:加上 increment 之后, key 的值。
- DECRBY key decrement //将key所储存的值减去decrement,如果 key 不存在,那么 key 的值会先被初始化为 0 ,然后再执行 DECRBY 操作。返回值:减去 decrement 之后, key 的值。
string应用场景
对象缓存
SET user:1 value(json格式数据) //但是这样存的话,如果要改某一个属性呢?那得改这个json,如果经常修改的话,使用批量好些(下面MSET)
MSET user:1:name libo 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 //防止程序意外终止导致死锁 ex:过期时间(秒),nx:不存在就设置
计数器
INCR article:readcount:{文章id}
GET article:readcount:{文章id}
Web集群session共享
spring session + redis实现session共享
分布式系统全局序列号
INCR orderId //每次加一,但这个不太好,如果请求的多了,那redis就只做id自加的事情就没了
INCRBY orderId 1000 //redis批量生成序列号提升性能,一次性取多个,然后保存在内存中,用完了再从redis中取。也就是自加1000
hash结构
常用操作命令介绍
HSET key field value //将哈希表 key 中的域 field 的值设为 value 。如果 key 不存在,一个新的哈希表被创建并进行 HSET 操作。如果域 field 已经存在于哈希表中,旧值将被覆盖。
在Redis 4.0.0以上,HSET是可变的,允许多个字段/值对。也就是:HSET key field value [field value ...]
返回值:
如果 field 是哈希表中的一个新建域,并且值设置成功,返回 1 。
如果哈希表中域 field 已经存在且旧值已被新值覆盖,返回 0
在Redis 4.0.0以上,HSET是可变的,允许多个字段/值对。也就是:HSET key field value [field value ...]
返回值:
如果 field 是哈希表中的一个新建域,并且值设置成功,返回 1 。
如果哈希表中域 field 已经存在且旧值已被新值覆盖,返回 0
HSETNX key field value //将哈希表 key 中的域 field 的值设置为 value ,当且仅当域 field 不存在。
//若域 field 已经存在,该操作无效。如果 key 不存在,一个新哈希表被创建并执行 HSETNX 命令。
返回值:
设置成功,返回 1 。
如果给定域已经存在且没有操作被执行,返回 0 。
//若域 field 已经存在,该操作无效。如果 key 不存在,一个新哈希表被创建并执行 HSETNX 命令。
返回值:
设置成功,返回 1 。
如果给定域已经存在且没有操作被执行,返回 0 。
//同时将多个 field-value (域-值)对设置到哈希表 key 中。此命令会覆盖哈希表中已存在的域。如果 key 不存在,一个空哈希表被创建并执行 HMSET 操作。
HMSET key field value [field value ...]
HMSET key field value [field value ...]
//获取哈希表key对应的field键值,这个不可以一次性获取多个field
HGET key field
HGET key field
//批量获取哈希表key中多个field键值
HMGET key field [field ...]
HMGET key field [field ...]
//删除哈希表 key 中的一个或多个指定域,不存在的域将被忽略。
HDEL key field [field ...]
HDEL key field [field ...]
//返回哈希表key中field的数量,当 key 不存在时,返回 0
HLEN key
HLEN key
//返回哈希表key中所有的键值
HGETALL key
HGETALL key
//为哈希表key中field键的值加上增量increment,增量也可以为负数,相当于对给定域进行减法操作。
HINCRBY key field increment //原子自加
如果 key 不存在,一个新的哈希表被创建并执行 HINCRBY 命令。
如果域 field 不存在,那么在执行命令前,域的值被初始化为 0 。
返回值:执行 HINCRBY 命令之后,哈希表 key 中域 field 的值。
HINCRBY key field increment //原子自加
如果 key 不存在,一个新的哈希表被创建并执行 HINCRBY 命令。
如果域 field 不存在,那么在执行命令前,域的值被初始化为 0 。
返回值:执行 HINCRBY 命令之后,哈希表 key 中域 field 的值。
应用场景
对象缓存
//HMSET user {userId}:name libo {userId}:balance 1888
HMSET user 1:name libo 1:balance 1888
HMGET user 1:name 1:balance
电商购物车
以<cart:用户id>为key:cart:1001
商品id为field
商品数量为value
# 购物车操作
商品id为field
商品数量为value
# 购物车操作
- 添加商品一:hset cart:1001 10088 1
- 添加商品二:hset cart:1001 10089 1
- 增加商品一数量:hincrby cart:1001 10088 1
- 商品总数:hlen cart:1001
- 删除商品:hdel cart:1001 10088
- 获取购物车所有商品:hgetall cart:1001
Hash结构优缺点
优点
- 同类数据归类整合储存,方便数据管理
- 相比string操作消耗内存与cpu更小(后面会分析)
- 相比string储存更节省空间(后面会分析)
缺点
- 过期功能不能使用在field上,只能用在key上
- Redis集群架构下不适合大规模使用(后面会讲到)
list结构
List常用操作命令介绍
LPUSH key value [value ...] //将一个或多个值value插入到key列表的表头(最左边)
//如果有多个 value 值,那么各个 value 值按从左到右的顺序依次插入到表头: 比如说,对空列表 mylist 执行命令 LPUSH mylist a b c ,列表的值将是 c b a ,
//这等同于原子性地执行 LPUSH mylist a 、 LPUSH mylist b 和 LPUSH mylist c 三个命令。
返回值:执行 LPUSH 命令后,列表的长度。
//如果有多个 value 值,那么各个 value 值按从左到右的顺序依次插入到表头: 比如说,对空列表 mylist 执行命令 LPUSH mylist a b c ,列表的值将是 c b a ,
//这等同于原子性地执行 LPUSH mylist a 、 LPUSH mylist b 和 LPUSH mylist c 三个命令。
返回值:执行 LPUSH 命令后,列表的长度。
RPUSH key value [value ...]//将一个或多个值value插入到key列表的表尾(最右边)
//如果有多个 value 值,那么各个 value 值按从左到右的顺序依次插入到表尾:比如对一个空列表 mylist 执行 RPUSH mylist a b c ,得出的结果列表为 a b c ,
//等同于执行命令 RPUSH mylist a 、 RPUSH mylist b 、 RPUSH mylist c 。
返回值:执行 RPUSH 操作后,表的长度。
//如果有多个 value 值,那么各个 value 值按从左到右的顺序依次插入到表尾:比如对一个空列表 mylist 执行 RPUSH mylist a b c ,得出的结果列表为 a b c ,
//等同于执行命令 RPUSH mylist a 、 RPUSH mylist b 、 RPUSH mylist c 。
返回值:执行 RPUSH 操作后,表的长度。
非阻塞移除并拿出元素:
- LPOP key//移除并返回key列表的头元素
- RPOP key//移除并返回key列表的尾元素
阻塞移除并拿出元素:
- BLPOP key [key ...] timeout //从key列表表头弹出一个元素,若列表中没有元素,阻塞等待 timeout秒,如果timeout=0,一直阻塞等待
- BRPOP key [key ...] timeout //从key列表表尾弹出一个元素,若列表中没有元素,阻塞等待 timeout秒,如果timeout=0,一直阻塞等待
//返回列表key中指定区间内的元素,区间以偏移量start和stop指定
LRANGE key start stop
//下标(index)参数 start 和 stop 都以 0 为底,也就是说,以 0 表示列表的第一个元素,以 1 表示列表的第二个元素,以此类推。
//你也可以使用负数下标,以 -1 表示列表的最后一个元素, -2 表示列表的倒数第二个元素,以此类推。
超出范围的下标值不会引起错误。
如果 start 下标比列表的最大下标 end ( LLEN list 减去 1 )还要大,那么 LRANGE 返回一个空列表。(empty list or set)
如果 stop 下标比 end 下标还要大,Redis将 stop 的值设置为 end 。
LRANGE key start stop
//下标(index)参数 start 和 stop 都以 0 为底,也就是说,以 0 表示列表的第一个元素,以 1 表示列表的第二个元素,以此类推。
//你也可以使用负数下标,以 -1 表示列表的最后一个元素, -2 表示列表的倒数第二个元素,以此类推。
超出范围的下标值不会引起错误。
如果 start 下标比列表的最大下标 end ( LLEN list 减去 1 )还要大,那么 LRANGE 返回一个空列表。(empty list or set)
如果 stop 下标比 end 下标还要大,Redis将 stop 的值设置为 end 。
应用场景
实现常用数据结构
Stack(栈) = LPUSH + LPOP //先进后出
Queue(队列)= LPUSH + RPOP //先进先出
Blocking MQ(阻塞队列)= LPUSH + BRPOP //先进先出 没有就阻塞
微博和微信公号消息流
命令操作:
小明关注了MacTalk,备胎说车等大V
1)MacTalk发微博,消息ID为10018
LPUSH msg:{小明-ID} 10018
2)备胎说车发微博,消息ID为10086
LPUSH msg:{小明-ID} 10086
3)查看最新微博消息
LRANGE msg:{小明-ID} 0 4
小明关注了MacTalk,备胎说车等大V
1)MacTalk发微博,消息ID为10018
LPUSH msg:{小明-ID} 10018
2)备胎说车发微博,消息ID为10086
LPUSH msg:{小明-ID} 10086
3)查看最新微博消息
LRANGE msg:{小明-ID} 0 4
在MacTalk跟备胎说车发微博的时候会往订阅者小明的list中存入,list{10018,10086},然后小明查看最新的消息就一条命令搞定。每次MacTalk发送一条消息的时候需要往每个订阅者的list添加一条消息
如果说这个MacTalk的订阅者很多呢?那不得发好多好多消息,一种解决方案就是:可以先给在线的人发消息,比如我订阅者总共5000人,在线才500个,那么我就会先发送给这500个在线的人,其他的异步发送,发送500个消息还是很快的对于redis来说,毕竟每秒能达到10w的命令。
如果说订阅者到了上千万甚至更高呢?就算我只给在线的发,那也需要发送很多很多。这种情况可以采用:MacTalk发送消息后,发送到MacTalk自己的消息list中,然后用户再从这个list中取,那如果是订阅的公众号不止一个,就需要先取出来然后在内存中进行时间排序,当然那这个消息的格式也需要稍微变动,比如<消息id:time>,不要觉得一次性取出来很多,一个公众号能发多少消息?就算多了那我是不是可以只取这个月的?而且在取的时候本身就需要根据时间来分页取,如果这个时间段没有那继续取更早的时间,直到取到我想要的消息。
如果说订阅者到了上千万甚至更高呢?就算我只给在线的发,那也需要发送很多很多。这种情况可以采用:MacTalk发送消息后,发送到MacTalk自己的消息list中,然后用户再从这个list中取,那如果是订阅的公众号不止一个,就需要先取出来然后在内存中进行时间排序,当然那这个消息的格式也需要稍微变动,比如<消息id:time>,不要觉得一次性取出来很多,一个公众号能发多少消息?就算多了那我是不是可以只取这个月的?而且在取的时候本身就需要根据时间来分页取,如果这个时间段没有那继续取更早的时间,直到取到我想要的消息。
在生产中使用可能还需要分公众号去区分,订阅量多的就采用第二种(用户主动poll),订阅量少就采用第一种(消息发送者主动推送push)。当然也不只是这么简单,肯定会特别复杂,这里只是抛砖引玉
无序set结构
Set常用操作命令介绍
SADD key member [member ...] //往集合key中存入元素,元素存在则忽略,若key不存在则新建
返回值:被添加到集合中的新元素的数量,不包括被忽略的元素
返回值:被添加到集合中的新元素的数量,不包括被忽略的元素
SREM key member [member ...] //移除集合 key 中的一个或多个 member 元素,不存在的 member 元素会被忽略。
返回值:被成功移除的元素的数量,不包括被忽略的元素
返回值:被成功移除的元素的数量,不包括被忽略的元素
//获取集合key中所有元素
SMEMBERS key
SMEMBERS key
//获取集合key的元素个数
SCARD key
SCARD key
//判断member元素是否存在于集合key中
SISMEMBER key member
返回值:
如果 member 元素是集合的成员,返回 1 。
如果 member 元素不是集合的成员,或 key 不存在,返回 0
SISMEMBER key member
返回值:
如果 member 元素是集合的成员,返回 1 。
如果 member 元素不是集合的成员,或 key 不存在,返回 0
//从集合key中随机选出count个元素,元素不从key中删除
SRANDMEMBER key [count]
如果 count 为正数,且小于集合基数,那么命令返回一个包含 count 个元素的数组,数组中的元素各不相同。如果 count 大于等于集合基数,那么返回整个集合。
如果 count 为负数,那么命令返回一个数组,数组中的元素可能会重复出现多次,而数组的长度为 count 的绝对值
SRANDMEMBER key [count]
如果 count 为正数,且小于集合基数,那么命令返回一个包含 count 个元素的数组,数组中的元素各不相同。如果 count 大于等于集合基数,那么返回整个集合。
如果 count 为负数,那么命令返回一个数组,数组中的元素可能会重复出现多次,而数组的长度为 count 的绝对值
//从集合key中选出count个元素,元素从key中删除
SPOP key [count]
SPOP key [count]
Set集合运算操作:【也可以将运算结果存入新集合destination中,比如SINTERSTORE destination key [key ...]】
- SINTER key [key ...] //交集运算
- SUNION key [key ..] //并集运算
- SDIFF key1 [key2 ...] //差集运算 返回key1有的 key2没有的
应用场景
微信抽奖小程序
//点击参与抽奖加入集合 用户参与了就添加进去 --往集合key中存入元素
SADD key {userlD}
//查看参与抽奖所有用户 --查询集合中所有元素
SMEMBERS key
//抽取count名中奖者
SRANDMEMBER key [count] //随机抽出几个出来,这种适合只抽一次 --从集合中拿出几个元素但不删除
SPOP key [count]//这种适合抽取多次的,比如一等奖、二等奖。中了奖的就不能再抽了,所以需要抽出来就移除 --从集合中拿出几个元素且删除
微信微博点赞,收藏,标签
//点赞 --添加元素到集合中
SADD like:{消息ID} {用户ID}
//取消点赞 --删除集合中的某个元素
SREM like:{消息ID} {用户ID}
//检查用户是否点过赞 --判断元素是否在集合中
SISMEMBER like:{消息ID} {用户ID}
//获取点赞的用户列表 这里是显示所有的点赞人,如果说只显示点赞人为好友的情况呢?那我可以先全部拿出来然后再匹配 --获取集合中所有元素
SMEMBERS like:{消息ID}
//获取点赞用户数 --获取集合长度
SCARD like:{消息ID}
集合操作实现微博微信关注模型 ★
//小张关注的人:
xiaozhangSet-> {"xiaoming", "xiaowang"} //sadd xiaozhangSet "xiaoming" "xiaowang"
//小李关注的人:
xiaoliSet--> {"xiaozhang", "xiaowu", "xiaoming", "xiaozhao"} //sadd xiaoliSet "xiaozhang" "xiaowu" "xiaoming" "xiaozhao"
//小明关注的人:
xiaomingSet-> {"xiaozhang", "xiaoli", "xiaoming", "xiaozhao", "xiaowang") //sadd xiaomingSet "xiaozhang" "xiaoli" "xiaoming" "xiaozhao" "xiaowang"
//小王关注的人
xiaowangSet->{"xiaoli"} //sadd xiaowangSet "xiaoli"
xiaozhangSet-> {"xiaoming", "xiaowang"} //sadd xiaozhangSet "xiaoming" "xiaowang"
//小李关注的人:
xiaoliSet--> {"xiaozhang", "xiaowu", "xiaoming", "xiaozhao"} //sadd xiaoliSet "xiaozhang" "xiaowu" "xiaoming" "xiaozhao"
//小明关注的人:
xiaomingSet-> {"xiaozhang", "xiaoli", "xiaoming", "xiaozhao", "xiaowang") //sadd xiaomingSet "xiaozhang" "xiaoli" "xiaoming" "xiaozhao" "xiaowang"
//小王关注的人
xiaowangSet->{"xiaoli"} //sadd xiaowangSet "xiaoli"
#######################共同关注###############################
//小张和小李共同关注: --交集运算
SINTER xiaozhangSet xiaoliSet--> {"xiaoming"}
#######################可能认识###############################
======第一步方式一============通过小张关注的两个人的共同关注找到小李,说明这个小李可能是小张认识的
//小张关注的两个人小明和小王的共同关注: --交集运算
SINTER xiaomingSet xiaowangSet
======第一步方式二============通过小张关注的两个人是否都关注了小李,如果都关注了那么说明这个小李是小张认识的
//小张关注的人(小明)是否也关注他(小李): --是否包含
SISMEMBER xiaomingSet "xiaoli"
//小张关注的人(小王)是否也关注他(小李): --是否包含
SISMEMBER xiaowangSet "xiaoli"
======第二步============得到这个小李可能小张也认识,然后通过找小李关注的小张没关注的找到小张可能认识的
//小张可能认识的人: 差集运算
SDIFF xiaoliSet xiaozhangSet->("xiaozhao" "xiaozhang" "xiaowu"}
//小张和小李共同关注: --交集运算
SINTER xiaozhangSet xiaoliSet--> {"xiaoming"}
#######################可能认识###############################
======第一步方式一============通过小张关注的两个人的共同关注找到小李,说明这个小李可能是小张认识的
//小张关注的两个人小明和小王的共同关注: --交集运算
SINTER xiaomingSet xiaowangSet
======第一步方式二============通过小张关注的两个人是否都关注了小李,如果都关注了那么说明这个小李是小张认识的
//小张关注的人(小明)是否也关注他(小李): --是否包含
SISMEMBER xiaomingSet "xiaoli"
//小张关注的人(小王)是否也关注他(小李): --是否包含
SISMEMBER xiaowangSet "xiaoli"
======第二步============得到这个小李可能小张也认识,然后通过找小李关注的小张没关注的找到小张可能认识的
//小张可能认识的人: 差集运算
SDIFF xiaoliSet xiaozhangSet->("xiaozhao" "xiaozhang" "xiaowu"}
关于数据推荐扩展:比如这个共同关注的数据信息有什么用呢?生活中我们是不是有这种事情发生--昨天我跟我同学在讨论iphone12,然后今天打开淘宝居然就给我推荐了iPhone12。比如当我们两个都共同关注了小张,所以这个小张可能是我们身边的(淘宝知道),然后我们讨论了iPhone12(淘宝不知道),接着这个小张晚上回去就买了iPhone12(淘宝知道),然后淘宝就可能给我们推荐iPhone12
集合操作实现电商商品筛选
SADD brand:oppo R20 //oppo品牌手机集合
SADD brand:xiaomi xiaomi-10 //小米品牌手机集合
SADD brand:iPhone iphone12 //苹果品牌手机集合
SADD os:android R20 xiaomi-10 //操作系统为安卓的手机集合
SADD cpu:gaoTong R20 xiaomi-10 //cpu为高通的手机集合
SADD ram:8G R20 xiaomi-10 iphone12 //运行内存为8G的手机集合
=============然后我要找 操作系统=安卓 cpu=高通 运行内存=8G 的手机有哪些
SINTER os:android cpu:gaoTong ram:8G --> {R20、xiaomi-10} --交集运算
别说啥我要查找8G以上的手机怎么办,这里只是指出可以这么做,而不是完全支持全部业务
SADD brand:xiaomi xiaomi-10 //小米品牌手机集合
SADD brand:iPhone iphone12 //苹果品牌手机集合
SADD os:android R20 xiaomi-10 //操作系统为安卓的手机集合
SADD cpu:gaoTong R20 xiaomi-10 //cpu为高通的手机集合
SADD ram:8G R20 xiaomi-10 iphone12 //运行内存为8G的手机集合
=============然后我要找 操作系统=安卓 cpu=高通 运行内存=8G 的手机有哪些
SINTER os:android cpu:gaoTong ram:8G --> {R20、xiaomi-10} --交集运算
别说啥我要查找8G以上的手机怎么办,这里只是指出可以这么做,而不是完全支持全部业务
有序zset结构(SortedSet)
ZSet常用操作命令介绍
//往有序集合key中加入带分值元素
ZADD key score member [[score member]…]
//如果某个 member 已经是有序集的成员,那么更新这个 member 的 score 值,并通过重新插入这个 member 元素,来保证该 member 在正确的位置上。
//score 值可以是整数值或双精度浮点数
ZADD key score member [[score member]…]
//如果某个 member 已经是有序集的成员,那么更新这个 member 的 score 值,并通过重新插入这个 member 元素,来保证该 member 在正确的位置上。
//score 值可以是整数值或双精度浮点数
//从有序集合key中删除元素
ZREM key member [member …]
返回值:被成功移除的成员的数量,不包括被忽略的成员。
ZREM key member [member …]
返回值:被成功移除的成员的数量,不包括被忽略的成员。
//返回有序集合key中元素member的分值
ZSCORE key member
ZSCORE key member
//返回有序集 key 中成员 member 的排名。其中有序集成员按 score 值递增(从小到大)顺序排列。排名以 0 为底,也就是说, score 值最小的成员排名为 0 。
ZRANK key member
使用 ZREVRANK 命令可以获得成员按 score 值递减(从大到小)排列的排名。
ZRANK key member
使用 ZREVRANK 命令可以获得成员按 score 值递减(从大到小)排列的排名。
//为有序集合key中元素member的分值加上increment
ZINCRBY key increment member
可以通过传递一个负数值 increment ,让 score 减去相应的值
score 值可以是整数值或双精度浮点数。
当 key 不存在,或 member 不是 key 的成员时, ZINCRBY key increment member 等同于 ZADD key increment member 。
返回值:member 成员的新 score 值,以字符串形式表示
ZINCRBY key increment member
可以通过传递一个负数值 increment ,让 score 减去相应的值
score 值可以是整数值或双精度浮点数。
当 key 不存在,或 member 不是 key 的成员时, ZINCRBY key increment member 等同于 ZADD key increment member 。
返回值:member 成员的新 score 值,以字符串形式表示
//返回有序集合key中元素个数
ZCARD key
返回值:
当 key 存在且是有序集类型时,返回有序集的基数。
当 key 不存在时,返回 0 。
ZCARD key
返回值:
当 key 存在且是有序集类型时,返回有序集的基数。
当 key 不存在时,返回 0 。
//正序获取有序集合key从start下标到stop下标的元素,其中成员的位置按 score 值递增(从小到大)来排序。具有相同 score 值的成员按字典序(lexicographical order )来排列。
ZRANGE key start stop [WITHSCORES]
//倒序获取有序集合key从start下标到stop下标的元素
ZREVRANGE key start stop [WITHSCORES]
ZRANGE key start stop [WITHSCORES]
//倒序获取有序集合key从start下标到stop下标的元素
ZREVRANGE key start stop [WITHSCORES]
集合操作
//并集计算
ZUNIONSTORE destkey numkeys key [key ...]
计算给定的一个或多个有序集的并集,其中给定 key 的数量必须以 numkeys 参数指定,并将该并集(结果集)储存到 destination 。
默认情况下,结果集中某个成员的 score 值是所有给定集下该成员 score 值之 和 。
WEIGHTS
使用 WEIGHTS 选项,你可以为 每个 给定有序集 分别 指定一个乘法因子(multiplication factor),每个给定有序集的所有成员的 score 值在传递给聚合函数(aggregation function)之前
都要先乘以该有序集的因子。如果没有指定 WEIGHTS 选项,乘法因子默认设置为 1 。
AGGREGATE
使用 AGGREGATE 选项,你可以指定并集的结果集的聚合方式。
默认使用的参数 SUM ,可以将所有集合中某个成员的 score 值之 和 作为结果集中该成员的 score 值;使用参数 MIN ,可以将所有集合中某个成员的 最小 score 值作为结果集中该成员
的 score 值;而参数 MAX 则是将所有集合中某个成员的 最大 score 值作为结果集中该成员的 score 值
//交集计算
ZINTERSTORE destkey numkeys key [key …]
默认情况下,结果集中某个成员的 score 值是所有给定集下该成员 score 值之和.
其他的跟ZUNIONSTORE 是一样的
//并集计算
ZUNIONSTORE destkey numkeys key [key ...]
计算给定的一个或多个有序集的并集,其中给定 key 的数量必须以 numkeys 参数指定,并将该并集(结果集)储存到 destination 。
默认情况下,结果集中某个成员的 score 值是所有给定集下该成员 score 值之 和 。
WEIGHTS
使用 WEIGHTS 选项,你可以为 每个 给定有序集 分别 指定一个乘法因子(multiplication factor),每个给定有序集的所有成员的 score 值在传递给聚合函数(aggregation function)之前
都要先乘以该有序集的因子。如果没有指定 WEIGHTS 选项,乘法因子默认设置为 1 。
AGGREGATE
使用 AGGREGATE 选项,你可以指定并集的结果集的聚合方式。
默认使用的参数 SUM ,可以将所有集合中某个成员的 score 值之 和 作为结果集中该成员的 score 值;使用参数 MIN ,可以将所有集合中某个成员的 最小 score 值作为结果集中该成员
的 score 值;而参数 MAX 则是将所有集合中某个成员的 最大 score 值作为结果集中该成员的 score 值
//交集计算
ZINTERSTORE destkey numkeys key [key …]
默认情况下,结果集中某个成员的 score 值是所有给定集下该成员 score 值之和.
其他的跟ZUNIONSTORE 是一样的
应用场景
Zset集合操作实现排行榜
===============================热搜榜==========================
//点击新闻 每当有人点击了新闻 就给新闻的分值加一 --为有序集合key中元素member的分值加上increment
ZINCRBY hotNews:20190819 1 守护香港
//展示当日排行前十 --正序获取有序集合key从start下标到stop下标的元素,其中成员的位置按 score 值递增(从小到大)来排序
ZREVRANGE hotNews:20190819 0 9 WITHSCORES
===============================七日关注==========================
//七日搜索榜单计算 先求得20190813-20190819时间内的所有新闻的并集 --并集计算
ZUNIONSTORE hotNews:20190813-20190819 7 hotNews:20190813 hotNews:20190814... hotNews:20190819
//展示七日排行前十 --然后再找这个并集的排行 ----正序获取有序集合key从start下标到stop下标的元素,其中成员的位置按 score 值递增(从小到大)来排序
ZREVRANGE hotNews:20190813-20190819 0 9 WITHSCORES
应用场景总结
- 计数器:可以对 String 进行自增自减运算,从而实现计数器功能。Redis 这种内存型数据库的读写性能非常高,很适合存储频繁读写的计数量。
- 分布式ID生成:利用自增特性,一次请求一个大一点的步长如 incr 2000 ,缓存在本地使用,用完再请求。
- 海量数据统计:位图(bitmap):存储是否参过某次活动,是否已读谋篇文章,用户是否为会员, 日活统计。
- 会话缓存:可以使用 Redis 来统一存储多台应用服务器的会话信息。当应用服务器不再存储用户的会话信息,也就不再具有状态,一个用户可以请求任意一个应用服务器,从而更容易实现高可用性以及可伸缩性。
- 分布式队列/阻塞队列:List 是一个双向链表,可以通过 lpush/rpush 和 rpop/lpop 写入和读取消息。可以通过使用brpop/blpop 来实现阻塞队列。
- 分布式锁实现:在分布式场景下,无法使用基于进程的锁来对多个节点上的进程进行同步。可以使用 Redis 自带的 SETNX 命令实现分布式锁。
- 热点数据存储:最新评论,最新文章列表,使用list 存储,ltrim取出热点数据,删除老数据。
- 社交类需求:Set 可以实现交集,从而实现共同好友等功能,Set通过求差集,可以进行好友推荐,文章推荐。
- 排行榜:sorted_set可以实现有序性操作,从而实现排行榜等功能。
- 延迟队列:使用sorted_set,使用 【当前时间戳 + 需要延迟的时长】做score, 消息内容作为元素,调用zadd来生产消息,消费者使用zrangbyscore获取当前时间之前的数据做轮询处理。消费完再删除任务 rem key member (可以直接使用带移除的命令:ZREMRANGEBYRANK key start stop)
其他高级命令
keys全量遍历键
KEYS pattern //查找所有符合给定模式 pattern 的 key
- KEYS * 匹配数据库中所有 key 。
- KEYS h?llo 匹配 hello , hallo 和 hxllo 等。
- KEYS h*llo 匹配 hllo 和 heeeeello 等。
- KEYS h[ae]llo 匹配 hello 和 hallo ,但不匹配 hillo 。
用来列出所有满足特定正则字符串规则的key,当redis数据量比较大时,性能比较差,要避免使用
scan:渐进式遍历键
SCAN cursor [MATCH pattern] [COUNT count]
scan 命令提供了三个参数,第一个是 cursor 整数值(hash桶的索引值),第二个是 key 的正则模式,第三个是一次遍历的key的数量(参考值,底层遍历的数量不一定),并不是符合条件的结果数量。第一次遍历时,cursor 值为 0,然后将返回结果中第一个整数值作为下一次遍历的 cursor。一直遍历到返回的 cursor 值为 0 时结束
scan 命令提供了三个参数,第一个是 cursor 整数值(hash桶的索引值),第二个是 key 的正则模式,第三个是一次遍历的key的数量(参考值,底层遍历的数量不一定),并不是符合条件的结果数量。第一次遍历时,cursor 值为 0,然后将返回结果中第一个整数值作为下一次遍历的 cursor。一直遍历到返回的 cursor 值为 0 时结束
注意:但是scan并非完美无瑕, 如果在scan的过程中如果有键的变化(增加、 删除、 修改) ,那么遍历效果可能会碰到如下问题: 新增的键可能没有遍历到, 遍历出了重复的键等情况, 也就是说scan并不能保证完整的遍历出来所有的键, 这些是我们在开发时需要考虑的
理解scan的cursor:我们的redis都是存的<key-value>的数据,不同的数据结构类型value不同而已。所以redis会有一个类似hash的桶,用来定位key的,当桶不够用的时候会触发rehash。所以这个cursor值的就是hash桶的索引值
Info:查看redis服务运行信息
分为 9 大块,每个块都有非常多的参数,这 9 个块分别是:
- Server 服务器运行的环境参数
- Clients 客户端相关信息
- Memory 服务器运行内存统计数据
- Persistence 持久化信息
- Stats 通用统计数据
- Replication 主从复制相关信息
- CPU CPU 使用情况
- Cluster 集群信息
- KeySpace 键值对统计数量信息(通过这个获取到key的数量)
通过src/redis-benchmark 测试每秒处理量
//测试每秒能处理多少get命令
[root@redis01 redis-5.0.5]# src/redis-benchmark get
====== get ======
100000 requests completed in 1.03 seconds//100000个请求在1.03秒内完成
50 parallel clients
3 bytes payload
keep alive: 1
99.88% <= 1 milliseconds
100.00% <= 2 milliseconds
96993.21 requests per second
[root@redis01 redis-5.0.5]# src/redis-benchmark get
====== get ======
100000 requests completed in 1.03 seconds//100000个请求在1.03秒内完成
50 parallel clients
3 bytes payload
keep alive: 1
99.88% <= 1 milliseconds
100.00% <= 2 milliseconds
96993.21 requests per second
学会使用help命令
命令记不住那么多的,所以要学会查看帮助,只要知道有那个东西在就行
127.0.0.1:6379> help set
SET key value [expiration EX seconds|PX milliseconds] [NX|XX]
summary: Set the string value of a key
since: 1.0.0
group: string
127.0.0.1:6379> help set
SET key value [expiration EX seconds|PX milliseconds] [NX|XX]
summary: Set the string value of a key
since: 1.0.0
group: string
Redis BitMap
bitMap使用
Bitmap(即Bitset),Bitmap是一串连续的2进制数字(0或1),每一位所在的位置为偏移(offset),在bitmap上可执行AND,OR,XOR以及其它位操作。其实也是一个string
位图计数(Population Count):位图计数统计的是bitmap中值为1的位的个数。位图计数的效率很高,例如,一个bitmap包含10亿个位,90%的位都置为1,在一台MacBook Pro上对其做位图计数需要21.1ms。SSE4甚至有对整形(integer)做位图计数的硬件指令
SETBIT key offset value
对 key 所储存的字符串值,设置或清除指定偏移量上的位(bit)
位的设置或清除取决于 value 参数,可以是 0 也可以是 1
当 key 不存在时,自动生成一个新的字符串值。
字符串会进行伸展(grown)以确保它可以将 value 保存在指定的偏移量上。当字符串值进行伸展时,空白位置以 0 填充。
offset 参数必须大于或等于 0 ,小于 2^32 (bit 映射被限制在 512 MB 之内)。
返回值:指定偏移量原来储存的位
GETBIT key offset
对 key 所储存的字符串值,获取指定偏移量上的位(bit)。
当 offset 比字符串值的长度大,或者 key 不存在时,返回 0
返回值:字符串值指定偏移量上的位(bit)
BITCOUNT key [start] [end]
计算给定字符串中,被设置为 1 的比特位的数量。
一般情况下,给定的整个字符串都会被进行计数,通过指定额外的 start 或 end 参数,可以让计数只在特定的位上进行。
start 和 end 参数的设置和 GETRANGE 命令类似,都可以使用负数值:比如 -1 表示最后一个位,而 -2 表示倒数第二个位,以此类推。
不存在的 key 被当成是空字符串来处理,因此对一个不存在的 key 进行 BITCOUNT 操作,结果为 0
返回值:被设置为 1 的位的数量
BITOP operation destkey key [key ...]
对一个或多个保存二进制位的字符串 key 进行位元操作,并将结果保存到 destkey 上
operation 可以是 AND 、 OR 、 NOT 、 XOR 这四种操作中的任意一种
对 key 所储存的字符串值,设置或清除指定偏移量上的位(bit)
位的设置或清除取决于 value 参数,可以是 0 也可以是 1
当 key 不存在时,自动生成一个新的字符串值。
字符串会进行伸展(grown)以确保它可以将 value 保存在指定的偏移量上。当字符串值进行伸展时,空白位置以 0 填充。
offset 参数必须大于或等于 0 ,小于 2^32 (bit 映射被限制在 512 MB 之内)。
返回值:指定偏移量原来储存的位
GETBIT key offset
对 key 所储存的字符串值,获取指定偏移量上的位(bit)。
当 offset 比字符串值的长度大,或者 key 不存在时,返回 0
返回值:字符串值指定偏移量上的位(bit)
BITCOUNT key [start] [end]
计算给定字符串中,被设置为 1 的比特位的数量。
一般情况下,给定的整个字符串都会被进行计数,通过指定额外的 start 或 end 参数,可以让计数只在特定的位上进行。
start 和 end 参数的设置和 GETRANGE 命令类似,都可以使用负数值:比如 -1 表示最后一个位,而 -2 表示倒数第二个位,以此类推。
不存在的 key 被当成是空字符串来处理,因此对一个不存在的 key 进行 BITCOUNT 操作,结果为 0
返回值:被设置为 1 的位的数量
BITOP operation destkey key [key ...]
对一个或多个保存二进制位的字符串 key 进行位元操作,并将结果保存到 destkey 上
operation 可以是 AND 、 OR 、 NOT 、 XOR 这四种操作中的任意一种
场景-日活跃用户
海量数据统计:位图(bitmap):存储是否参过某次活动,是否已读谋篇文章,用户是否为会员, 日活统计
为了统计今日登录的用户数,我们建立了一个bitmap,每一位标识一个用户ID。当某个用户访问我们的网页或执行了某个操作,就在bitmap中把标识此用户的位置为1
#比如2020-11-09日用户id为100的用户登录了
用户登录:setbit user:active:20201109 100 1
用户下线:setbit user:active:20201109 100 0
判断用户是否在线:getbit user:active:20201109 100
统计月活,就是遍历bitmap数组,求和:for(1:30)BitSet.valueOf(redis.get(date[i])).cardinality()
统计连续两日登录可以求逻辑并
用户登录:setbit user:active:20201109 100 1
用户下线:setbit user:active:20201109 100 0
判断用户是否在线:getbit user:active:20201109 100
统计月活,就是遍历bitmap数组,求和:for(1:30)BitSet.valueOf(redis.get(date[i])).cardinality()
统计连续两日登录可以求逻辑并
Redis GEO(存储地理位置信息)
GeoHash是一种地理位置编码方法。 由Gustavo Niemeyer 和 G.M. Morton于2008年发明,它将地理位置编码为一串简短的字母和数字。它是一种分层的空间数据结构,将空间细分为网格形状的桶,这是所谓的z顺序曲线的众多应用之一,通常是空间填充曲线。底层通过跳表实现,跟redis zset一样
Redis持久化、主从与哨兵架构详解
Redis持久化
RDB快照(snapshot)
RDB快照策略配置与save、bgsave
在默认情况下, Redis 将内存数据库快照保存在名字为 dump.rdb 的二进制文件中。当启动的时候会重新加载到内存。文件内容为当时的内存快照二进制文件:
//N 秒内数据集至少有 M 个改动”这一条件被满足时, 自动保存一次数据集
save 60 1000 //60 秒内有至少有 1000 个键被改动,关闭RDB只需要将所有的save保存策略注释掉即可
默认存在三种save策略:
save 60 1000 //60 秒内有至少有 1000 个键被改动,关闭RDB只需要将所有的save保存策略注释掉即可
默认存在三种save策略:
- save 900 1
- save 300 10
- save 60 10000
执行命令save或bgsave可以生成dump.rdb文件,每次命令执行都会将所有redis内存快照到一个新的rdb文件里,并覆盖原有rdb快照文件
bgsave的写时复制(COW)机制
Redis 借助操作系统提供的写时复制技术(Copy-On-Write, COW),在生成快照的同时,依然可以正常处理写命令。简单来说,bgsave 子进程是由主线程 fork 生成的,可以共享主线程的所有内存数据。bgsave 子进程运行后,开始读取主线程的内存数据,并把它们写入 RDB 文件。此时,如果主线程对这些数据也都是读操作,那么,主线程和 bgsave 子进程相互不影响。但是,如果主线程要修改一块数据,那么,这块数据就会被复制一份,生成该数据的副本,所以在快照持久化期间修改的数据不会被保存。然后,bgsave 子进程会把这个副本数据写入 RDB 文件,而在这个过程中,主线程仍然可以直接修改原来的数据。(说白了就是当写的时候,就会复制一份副本(这个副本已经写完)给bgsave进程,然后bgsave就把这个副本保存到RDB文件中去)
写时复制“技术(COW):两个进程用的是相同的物理空间(内存区),子进程的代码段、数据段、堆栈都是指向父进程的物理空间,也就是说,两者的虚拟空间不同,但其对应的物理空间是同一个。当父子进程中有更改相应段的行为发生时,再为子进程相应的段分配物理空间,父进程使用原来那块空间
写时复制“技术(COW):两个进程用的是相同的物理空间(内存区),子进程的代码段、数据段、堆栈都是指向父进程的物理空间,也就是说,两者的虚拟空间不同,但其对应的物理空间是同一个。当父子进程中有更改相应段的行为发生时,再为子进程相应的段分配物理空间,父进程使用原来那块空间
save与bgsave对比
配置自动生成rdb文件后台使用的是bgsave方式。
丢失数据:如果 Redis 因为某些原因而造成故障停机, 那么服务器将丢失最近写入、且仍未保存到快照中的那些数据
丢失数据:如果 Redis 因为某些原因而造成故障停机, 那么服务器将丢失最近写入、且仍未保存到快照中的那些数据
save同步执行,会阻塞客户端命令,不会消耗额外内存,bgsave由子进程异步执行,不会阻塞客户端命令,但会消耗额外内存
AOF(append-only file)
aof基本介绍
从 1.1 版本开始, Redis 增加了一种完全耐久的持久化方式: AOF 持久化,将修改的每一条指令记录进文件appendonly.aof中(先写入os cache,每隔一段时间fsync到磁盘)
这是一种resp协议格式数据,星号后面的数字代表命令有多少个参数,$号后面的数字代表这个参数有几个字符(第一个$3代表set有三个字符)
注意,如果执行带过期时间的set命令,aof文件里记录的是并不是执行的原始命令,而是记录key过期的时间戳
注意,如果执行带过期时间的set命令,aof文件里记录的是并不是执行的原始命令,而是记录key过期的时间戳
开启aof和策略配置
打开方式:默认是没有打开的,你可以通过修改配置文件来打开 AOF 功能:
appendonly yes
appendonly yes
每当 Redis 执行一个改变数据集的命令时(比如 SET), 这个命令就会被追加到 AOF 文件的末尾。这样的话, 当 Redis 重新启动时, 程序就可以通过重新执行 AOF 文件中的命令来达到重建数据集的目的
可以配置 Redis 多久才将数据 fsync 到磁盘一次。有三个选项:
- appendfsync always:每次有新命令追加到 AOF 文件时就执行一次 fsync ,非常慢,也非常安全。最多丢失一条命令
- appendfsync everysec:每秒 fsync 一次,足够快,并且在故障时只会丢失 1 秒钟的数据。当打开aof后默认为这个策略
- appendfsync no:从不 fsync ,将数据交给操作系统来处理。更快,也更不安全的选择
AOF重写
AOF文件里可能有太多没用指令,所以AOF会定期根据内存的最新数据生成aof文件
如下两个配置可以控制AOF自动重写频率
当然AOF还可以手动重写,进入redis客户端执行命令bgrewriteaof重写AOF
- # auto-aof-rewrite-min-size 64mb //aof文件至少要达到64M才会自动重写,文件太小恢复速度本来就很快,重写的意义不大
- # auto-aof-rewrite-percentage 100 //aof文件自上一次重写后文件大小增长了100%则再次触发重写
当然AOF还可以手动重写,进入redis客户端执行命令bgrewriteaof重写AOF
注意,AOF重写redis会fork出一个子进程去做(与bgsave命令类似),也就是说当重写的时候会拷贝一份副本(已写完),然后aof还能继续往原aof文件添加数据,不会对redis正常命令处理有太多影响
RDB 和 AOF ,我应该用哪一个?
生产环境可以都启用,redis启动时如果既有rdb文件又有aof文件则优先选择aof文件恢复数据,因为aof一般来说数据更全一点
Redis 4.0 混合持久化
重启 Redis 时,我们很少使用 RDB来恢复内存状态,因为会丢失大量数据。我们通常使用 AOF 日志重放,但是重放 AOF 日志性能【写是没关系的,一样快】相对 RDB来说要慢很多,这样在 Redis 实例很大的情况下,启动需要花费很长的时间。 Redis 4.0 为了解决这个问题,带来了一个新的持久化选项——混合持久化
通过如下配置可以开启混合持久化(必须先开启aof,而aof默认是没有打开的):
aof-use-rdb-preamble yes //默认是打开的
如果开启了混合持久化,AOF在重写时,不再是单纯将内存数据转换为RESP命令写入AOF文件,而是将重写这一刻之前的内存做RDB快照处理,并且将RDB快照内容和增量的AOF修改内存数据的命令存在一起,都写入新的AOF文件,新的文件一开始不叫appendonly.aof,等到重写完新的AOF文件才会进行改名,覆盖原有的AOF文件,完成新旧两个AOF文件的替换。
于是在 Redis 重启的时候,可以先加载 RDB 的内容,然后再重放增量 AOF 日志就可以完全替代之前的 AOF 全量文件重放,因此重启效率大幅得到提升
Redis数据备份策略推荐
- 写crontab定时调度脚本,每小时都copy一份rdb或aof的备份到一个目录中去,仅仅保留最近48小时的备份
- 每天都保留一份当日的数据备份到一个目录中去,可以保留最近1个月的备份
- 每次copy备份的时候,都把太旧的备份给删了
- 每天晚上将当前机器上的备份复制一份到其他机器上,以防机器损坏
Redis主从架构
Redis主从工作原理
全量复制
①:如果你为master配置了一个slave,不管这个slave是否是第一次连接上Master,它都会发送一个PSYNC命令给master请求复制数据。
②:master收到PSYNC命令后,会在后台进行数据持久化通过bgsave生成最新的rdb快照文件,持久化期间,master会继续接收客户端的请求,它会把这些可能修改数据集的请求缓存在内存中(所以这里只会把持久化期间的命令缓存起来,而非持久化的证明已经rdb文件已经发送完了,所以直接发送命令)。当持久化进行完毕以后,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的缓存队列里了,那么将会进行一次全量数据的复制。
master会在其内存中创建一个复制数据用的缓存队列,缓存最近一段时间的数据,master和它所有的slave都维护了复制的数据下标offset和master的进程id,因此,当网络连接断开后,slave会请求master继续进行未完成的复制,从所记录的数据下标开始。如果master进程id变化了,或者从节点数据下标offset太旧,已经不在master的缓存队列里了,那么将会进行一次全量数据的复制。
如果有很多从节点,为了缓解主从复制风暴(多个从节点同时复制主节点导致主节点压力过大),可以做如下架构,让部分从节点与从节点(与主节点同步)同步数据
默认配置了从节点只读,所以从节点还是可以读的,只是不能写
默认配置了从节点只读,所以从节点还是可以读的,只是不能写
Redis哨兵高可用架构
redis哨兵架构
sentinel/ˈsentɪnl/哨兵是特殊的redis服务,不提供读写服务,主要用来监控redis实例节点。
哨兵架构下client端第一次从哨兵找出redis的主节点,后续就直接访问redis的主节点,不会每次都通过sentinel代理访问redis的主节点,当redis的主节点发生变化,哨兵会第一时间感知到,并且将新的redis主节点通知给client端(这里面redis的client端一般都实现了订阅功能,订阅sentinel发布的节点变动消息)
哨兵架构下client端第一次从哨兵找出redis的主节点,后续就直接访问redis的主节点,不会每次都通过sentinel代理访问redis的主节点,当redis的主节点发生变化,哨兵会第一时间感知到,并且将新的redis主节点通知给client端(这里面redis的client端一般都实现了订阅功能,订阅sentinel发布的节点变动消息)
哨兵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节点类似
当原master的redis实例再次启动时,哨兵集群根据集群元数据信息就可以将原master的redis节点作为从节点加入集群
哨兵集群只有一个哨兵节点,redis的主从也能正常运行以及选举master,如果master挂了,那唯一的那个哨兵节点就是哨兵leader了,可以正常选举新master。不过为了高可用一般都推荐至少部署三个哨兵节点。为什么推荐奇数个哨兵节点原理跟集群奇数个master节点类似
当原master的redis实例再次启动时,哨兵集群根据集群元数据信息就可以将原master的redis节点作为从节点加入集群
redis管道与lua脚本
管道(Pipeline)
客户端可以一次性发送多个请求而不用等待服务器的响应,待所有命令都发送完后再一次性读取服务的响应,这样可以极大的降低多条命令执行的网络传输开销,管道执行多条命令的网络开销实际上只相当于一次命令执行的网络开销。需要注意到是用pipeline方式打包命令发送,redis必须在处理完所有命令前先缓存起所有命令的处理结果。打包的命令越多,缓存消耗内存也越多。所以并不是打包的命令越多好。
pipeline中发送的每个command都会被server立即执行,如果执行失败,将会在此后的响应中得到信息;也就是pipeline并不是表达“所有command都一起成功”的语义,管道中前面命令失败,后面命令不会有影响,继续执行,也就是不保证原子性
pipeline中发送的每个command都会被server立即执行,如果执行失败,将会在此后的响应中得到信息;也就是pipeline并不是表达“所有command都一起成功”的语义,管道中前面命令失败,后面命令不会有影响,继续执行,也就是不保证原子性
Redis Lua脚本
Redis在2.6推出了脚本功能,允许开发者使用Lua语言编写脚本传到Redis中执行。使用脚本的好处如下:
- 减少网络开销:本来5次网络请求的操作,可以用一个请求完成,原先5次请求的逻辑放在redis服务器上完成。使用脚本,减少了网络往返时延。这点跟管道类似。
- 原子操作:Redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。管道不是原子的,不过redis的批量操作命令(类似mset)是原子的。
- 替代redis的事务功能:redis自带的事务功能很鸡肋,报错不支持回滚,而redis的lua脚本几乎实现了常规的事务功能,支持报错回滚操作,官方推荐如果要使用redis的事务功能可以用redis lua替代。
注意,不要在Lua脚本中出现死循环和耗时的运算,否则redis会阻塞,将不接受其他的命令, 所以使用时要注意不能出现死循环、耗时的运算。redis是单线程执行脚本。管道不会阻塞redis客户端继续发送命令(在服务端未响应时,客户端可以继续向服务端发送请求)
Redis缓存高可用集群
Redis集群方案哨兵与cluster比较
哨兵模式
在redis3.0以前的版本要实现集群一般是借助哨兵sentinel工具来监控master节点的状态,如果master节点异常,则会做主从切换,将某一台slave作为master,哨兵的配置略微复杂,并且性能和高可用性等各方面表现一般,特别是在主从切换的瞬间存在访问瞬断的情况,而且哨兵模式只有一个主节点对外提供服务,没法支持很高的并发,且单个主节点内存也不宜设置得过大,否则会导致持久化文件过大,影响数据恢复或主从同步的效率
高可用集群模式
redis集群是一个由多个主从节点群组成的分布式服务器群,它具有复制、高可用和分片特性。Redis集群不需要sentinel哨兵·也能完成节点移除和故障转移的功能。需要将每个节点设置成集群模式,这种集群模式没有中心节点,可水平扩展,据官方文档称可以线性扩展到上万个节点(官方推荐不超过1000个节点)。redis集群的性能和高可用性均优于之前版本的哨兵模式,且集群配置非常简单
阿里云Tair-集群架构
代理模式
代理(proxy)模式,支持通过一个统一的连接地址(域名)访问Tair集群,客户端的请求通过代理服务器转发到各数据分片,代理服务器、数据分片和配置服务器均不提供单独的连接地址,降低了应用开发难度和代码复杂度。
集群架构代理模式组件说明
代理服务器(proxy servers)
单节点配置,集群架构中会有多个Proxy组成,系统会自动对其实现负载均衡及故障转移。
数据分片(data shards)
每个数据分片均为双副本(分别部署在不同机器上)高可用架构,主节点发生故障后,系统会自动进行主备切换保证服务高可用。
配置服务器(config server)
采用双副本高可用架构,用于存储集群配置信息及分区策略。
单节点配置,集群架构中会有多个Proxy组成,系统会自动对其实现负载均衡及故障转移。
数据分片(data shards)
每个数据分片均为双副本(分别部署在不同机器上)高可用架构,主节点发生故障后,系统会自动进行主备切换保证服务高可用。
配置服务器(config server)
采用双副本高可用架构,用于存储集群配置信息及分区策略。
简单理解就是集群中的分片等由代理来做,对于客户端来说它相当于一个单节点的redis
直连模式
因所有请求都要通过代理服务器转发,代理模式在降低业务开发难度的同时也会小幅度影响Tair服务的响应速度。如果业务对响应速度的要求非常高,您可以使用直连模式,绕过代理服务器直接连接后端数据分片,从而降低网络开销和服务响应时间
直连模式为类似连接原生Redis Cluster的方式连接集群。客户端首次连接时会通过DNS将直连地址解析为一个随机数据分片的虚拟IP(VIP)地址,之后即可通过Redis Cluster协议访问各数据分片。直连模式与代理模式的连接方式区别较大
持久内存型NVM
持久内存型(简称持久内存型)基于持久内存技术,为您提供大容量、兼容Redis的内存数据库产品。单实例成本对比Redis社区版最高可降低30%,且数据持久化不依赖传统磁盘,保证每个操作持久化的同时提供近乎Redis社区版的吞吐和延时,极大提升业务数据可靠性
冷热分离,热数据放普通内存,冷数据放nvm中
redis cluster原理与问题分析
redis cluster相关原理
Redis集群原理分析:
Redis Cluster 将所有数据划分为 16384(2的14次幂) 个 slots(槽位),每个节点负责其中一部分槽位。槽位的信息存储于每个节点中。
当 Redis Cluster 的客户端来连接集群时,它也会得到一份集群的槽位配置信息并将其缓存在客户端本地。这样当客户端要查找某个 key 时,可以直接定位到目标节点。同时因为槽位的信息可能会存在客户端与服务器不一致的情况,还需要纠正机制来实现槽位信息的校验调整
Redis Cluster 将所有数据划分为 16384(2的14次幂) 个 slots(槽位),每个节点负责其中一部分槽位。槽位的信息存储于每个节点中。
当 Redis Cluster 的客户端来连接集群时,它也会得到一份集群的槽位配置信息并将其缓存在客户端本地。这样当客户端要查找某个 key 时,可以直接定位到目标节点。同时因为槽位的信息可能会存在客户端与服务器不一致的情况,还需要纠正机制来实现槽位信息的校验调整
槽位定位算法
Cluster 默认会对 key 值使用 crc16 算法进行 hash 得到一个整数值,然后用这个整数值对 16384 进行取模来得到具体槽位。
槽位定位是客户端完成的,比如Jedis它会实现,而服务端只是维护这种集群
HASH_SLOT = CRC16(key) mod 16384
当作水平扩展的时候需要为新增master分配hash slot(从所有主节点抽取),删除时同样需要将hash slot给到其他节点(只能全部归还给同一个主节点)
Cluster 默认会对 key 值使用 crc16 算法进行 hash 得到一个整数值,然后用这个整数值对 16384 进行取模来得到具体槽位。
槽位定位是客户端完成的,比如Jedis它会实现,而服务端只是维护这种集群
HASH_SLOT = CRC16(key) mod 16384
当作水平扩展的时候需要为新增master分配hash slot(从所有主节点抽取),删除时同样需要将hash slot给到其他节点(只能全部归还给同一个主节点)
跳转重定位
当客户端向一个错误的节点发出了指令,该节点会发现指令的 key 所在的槽位并不归自己管理,这时它会向客户端发送一个特殊的跳转指令携带目标操作的节点地址,告诉客户端去连这个节点去获取数据。客户端收到指令后除了跳转到正确的节点上去操作,还会同步更新纠正本地的槽位映射表缓存,后续所有 key 将使用新的槽位映射表
所以说客户端跟服务器每一台集群节点都有维护这个槽位的分布情况,以上就是他们的纠正机制
当客户端向一个错误的节点发出了指令,该节点会发现指令的 key 所在的槽位并不归自己管理,这时它会向客户端发送一个特殊的跳转指令携带目标操作的节点地址,告诉客户端去连这个节点去获取数据。客户端收到指令后除了跳转到正确的节点上去操作,还会同步更新纠正本地的槽位映射表缓存,后续所有 key 将使用新的槽位映射表
所以说客户端跟服务器每一台集群节点都有维护这个槽位的分布情况,以上就是他们的纠正机制
Redis集群节点间的通信机制
redis cluster节点间采取gossip /ˈɡɑːsɪp/ 协议进行通信
维护集群的元数据(集群节点信息,主从角色,节点数量,各节点共享的数据等)有两种方式:集中式和gossip
集中式: 优点在于元数据的更新和读取,时效性非常好,一旦元数据出现变更立即就会更新到集中式的存储中,其他节点读取的时候立即就可以感知到;不足在于所有的元数据的更新压力全部集中在一个地方,可能导致元数据的存储压力。 很多中间件都会借助zookeeper集中式存储元数据
gossip:gossip协议包含多种消息,包括ping,pong,meet,fail等等。
gossip通信的10000端口:每个节点都有一个专门用于节点间gossip通信的端口,就是自己提供服务的端口号+10000,比如7001,那么用于节点间通信的就是17001端口。 每个节点每隔一段时间都会往另外几个节点发送ping消息,同时其他节点接收到ping消息之后返回pong消息
redis cluster节点间采取gossip /ˈɡɑːsɪp/ 协议进行通信
维护集群的元数据(集群节点信息,主从角色,节点数量,各节点共享的数据等)有两种方式:集中式和gossip
集中式: 优点在于元数据的更新和读取,时效性非常好,一旦元数据出现变更立即就会更新到集中式的存储中,其他节点读取的时候立即就可以感知到;不足在于所有的元数据的更新压力全部集中在一个地方,可能导致元数据的存储压力。 很多中间件都会借助zookeeper集中式存储元数据
gossip:gossip协议包含多种消息,包括ping,pong,meet,fail等等。
- meet:某个节点发送meet给新加入的节点,让新节点加入集群中,然后新节点就会开始与其他节点进行通信;
- ping:每个节点都会频繁给其他节点发送ping,其中包含自己的状态还有自己维护的集群元数据,互相通过ping交换元数据(类似自己感知到的集群节点增加和移除,hash slot信息等);
- pong: 对ping和meet消息的返回,包含自己的状态和其他信息,也可以用于信息广播和更新;
- fail: 某个节点判断另一个节点fail之后,就发送fail给其他节点,通知其他节点,指定的节点宕机了
gossip通信的10000端口:每个节点都有一个专门用于节点间gossip通信的端口,就是自己提供服务的端口号+10000,比如7001,那么用于节点间通信的就是17001端口。 每个节点每隔一段时间都会往另外几个节点发送ping消息,同时其他节点接收到ping消息之后返回pong消息
相关问题分析
网络抖动造成脑裂问题
真实世界的机房网络往往并不是风平浪静的,它们经常会发生各种各样的小问题。比如网络抖动就是非常常见的一种现象,突然之间部分连接变得不可访问,然后很快又恢复正常。
为解决这种问题,Redis Cluster 提供了一种选项cluster-node-timeout,表示当某个节点持续 timeout 的时间失联时,才可以认定该节点出现故障,需要进行主从切换。如果没有这个选项,网络抖动会导致主从频繁切换 (数据的重新复制)。所以说这个值不能配置的太小,不然别个只是抖动了一下你就认为别人挂了,很容易出现脑裂
脑裂问题产生:集群跟原master网络不通,选举新master,但是客户端跟原master网络通的,客户端在没有感知到新master的情况下继续往原master写数据,在哨兵跟cluster集群都会出现脑裂问题,后面他们都会告知客户端新master。
为解决这种问题,Redis Cluster 提供了一种选项cluster-node-timeout,表示当某个节点持续 timeout 的时间失联时,才可以认定该节点出现故障,需要进行主从切换。如果没有这个选项,网络抖动会导致主从频繁切换 (数据的重新复制)。所以说这个值不能配置的太小,不然别个只是抖动了一下你就认为别人挂了,很容易出现脑裂
脑裂问题产生:集群跟原master网络不通,选举新master,但是客户端跟原master网络通的,客户端在没有感知到新master的情况下继续往原master写数据,在哨兵跟cluster集群都会出现脑裂问题,后面他们都会告知客户端新master。
Redis集群选举原理分析
当slave发现自己的master变为FAIL状态时,便尝试进行Failover,以期望成为新的master。由于挂掉的master可能会有多个slave,从而存在多个slave竞争成为master节点的过程, 其过程如下:
从节点并不是在主节点一进入 FAIL 状态就马上尝试发起选举,而是有一定延迟,一定的延迟确保我们等待FAIL状态在集群中传播,slave如果立即尝试选举,其它masters或许尚未意识到FAIL状态,可能会拒绝投票
• 延迟计算公式:
DELAY = 500ms + random(0 ~ 500ms) + SLAVE_RANK * 1000ms
SLAVE_RANK表示此slave已经从master复制数据的总量的rank(排名)。Rank越小代表已复制的数据越新。也就是延迟的时间可能越短,这种方式下,持有最新数据的slave将会首先发起选举(理论上)。
- 1.slave发现自己的master变为FAIL
- 2.将自己记录的集群currentEpoch(类似选举次数)加1,并广播FAILOVER_AUTH_REQUEST 信息给其他每个节点
- 3.其他节点收到该信息,只有master响应,判断请求者的合法性(比如此节点是否为挂的master的从节点),并发送FAILOVER_AUTH_ACK,对每一个epoch(选举者)只发送一次ack
- 4.尝试failover的slave收集master返回的FAILOVER_AUTH_ACK数
- 5.slave收到超过半数master的ack后变成新Master(这里解释了集群为什么至少需要三个主节点,如果只有两个,当其中一个挂了,只剩一个主节点是不能选举成功的)
- 6.slave广播Pong消息通知其他集群节点,告诉他们master信息,不需要再继续选举了,我已经选举成功了。
从节点并不是在主节点一进入 FAIL 状态就马上尝试发起选举,而是有一定延迟,一定的延迟确保我们等待FAIL状态在集群中传播,slave如果立即尝试选举,其它masters或许尚未意识到FAIL状态,可能会拒绝投票
• 延迟计算公式:
DELAY = 500ms + random(0 ~ 500ms) + SLAVE_RANK * 1000ms
SLAVE_RANK表示此slave已经从master复制数据的总量的rank(排名)。Rank越小代表已复制的数据越新。也就是延迟的时间可能越短,这种方式下,持有最新数据的slave将会首先发起选举(理论上)。
集群脑裂数据丢失问题
redis集群没有过半机制会有脑裂问题,网络分区导致脑裂后多个主节点对外提供写服务,一旦网络分区恢复,会将其中一个主节点变为从节点,这时会有大量数据丢失。
比如当一个master出现了较长时间的网络抖动,然后其他的以为它已经挂了并且选出了新的master,当原master网络恢复后,也就出现了两个master,这就是脑裂。这个时候集群只会认新选出的master而不认原来的master,所以原来master会丢失集群在选举新master期间的数据。
集群脑裂问题中,如果客户端还在基于原来的master节点继续写入数据,那么新的master节点将无法同步这些数据,当原master网络恢复后,会将它设置成从节点,此时再从新的master中同步数据,将会造成大量的数据丢失(从节点加入master节点都会同步数据)
规避方法可以在redis配置里加上参数(这种方法不可能百分百避免数据丢失,参考集群leader选举机制):
min-slaves-to-write 1 //写数据成功最少同步的slave数量,这个数量可以参考大于半数机制配置,比如集群总共三个节点可以配置1,加上leader就是2,超过了半数
为什么说这样能有效避免数据丢失:每次写都至少同步数据到slave,这个时候当选举新master的时候,这个数据多的就最有可能成为master,也就能有效减少丢失数据
注意:这个配置在一定程度上会影响集群的可用性,比如slave要是少于1个,这个集群就算leader正常也不能提供服务了(因为没有slave可同步),需要具体场景权衡选择。
比如当一个master出现了较长时间的网络抖动,然后其他的以为它已经挂了并且选出了新的master,当原master网络恢复后,也就出现了两个master,这就是脑裂。这个时候集群只会认新选出的master而不认原来的master,所以原来master会丢失集群在选举新master期间的数据。
集群脑裂问题中,如果客户端还在基于原来的master节点继续写入数据,那么新的master节点将无法同步这些数据,当原master网络恢复后,会将它设置成从节点,此时再从新的master中同步数据,将会造成大量的数据丢失(从节点加入master节点都会同步数据)
规避方法可以在redis配置里加上参数(这种方法不可能百分百避免数据丢失,参考集群leader选举机制):
min-slaves-to-write 1 //写数据成功最少同步的slave数量,这个数量可以参考大于半数机制配置,比如集群总共三个节点可以配置1,加上leader就是2,超过了半数
为什么说这样能有效避免数据丢失:每次写都至少同步数据到slave,这个时候当选举新master的时候,这个数据多的就最有可能成为master,也就能有效减少丢失数据
注意:这个配置在一定程度上会影响集群的可用性,比如slave要是少于1个,这个集群就算leader正常也不能提供服务了(因为没有slave可同步),需要具体场景权衡选择。
集群是否完整才能对外提供服务
当redis.conf的配置cluster-require-full-coverage为no时,表示当负责一个插槽的主库下线且没有相应的从库进行故障恢复时,集群仍然可用,如果为yes则集群不可用。
默认注释了,也就是可用
# cluster-require-full-coverage yes
默认注释了,也就是可用
# cluster-require-full-coverage yes
Redis集群为什么至少需要三个master节点,并且推荐节点数为奇数?
因为新master的选举需要大于半数的集群master节点同意才能选举成功,如果只有两个master节点,当其中一个挂了,是达不到选举新master的条件的(半数以上为两个)。
奇数个master节点可以在满足选举该条件的基础上节省一个节点,比如三个master节点(半数以上为至少两个)和四个master节点的集群相比(半数以上都需要至少3个),大家如果都挂了一个master节点都能选举新master节点,如果都挂了两个master节点都没法选举新master节点了,所以奇数的master节点更多的是从节省机器资源角度出发说的
奇数个master节点可以在满足选举该条件的基础上节省一个节点,比如三个master节点(半数以上为至少两个)和四个master节点的集群相比(半数以上都需要至少3个),大家如果都挂了一个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。
如果slot值一样没关系,不一样就会报错,不允许
mset {user1}:1:name zhuge {user1}:1:age 18
假设name和age计算的hash slot值不一样,但是这条命令在集群下执行,redis只会用大括号里的 user1 做hash slot计算,所以算出来的slot值肯定相同,最后都能落在同一slot。
如果slot值一样没关系,不一样就会报错,不允许
深入底层c源码看redis核心设计原理
redis底层设计原理
redis字符串实现原理
redis字符串【sds】设计原理
redis所有的key其实都是string类型的。redis由c语言实现,通过网络流将我们的命令传输到redis-server端的时候,redis并不是使用的c语言字符串类型(c语言表示一个字符串:char str[]="libo";),而是自定义了一种类型SDS(simple dynamic string简单动态字符)
//redis 3.2 以前是这样表示,后面根据不同的长度来,后面会说到。
struct sdshdr {
int len;//这个buf的长度
int free;//buf剩余可用的长度
char buf[];//存储字符串的数组
};//比如buf总长度为8,buf[]="libo"使用了4个字节,len=4、free=8-4=4
//redis 3.2 以前是这样表示,后面根据不同的长度来,后面会说到。
struct sdshdr {
int len;//这个buf的长度
int free;//buf剩余可用的长度
char buf[];//存储字符串的数组
};//比如buf总长度为8,buf[]="libo"使用了4个字节,len=4、free=8-4=4
redis使用自定义类型sds优点:
- 二进制安全的数据结构:有很多语言都会使用redis,在c中字符串后面都会用 ‘\0’ 用来标识字符串(读到以'\0'结尾之前的字符),相对使用原始c语言字符类型,避免了本身数据带了 ‘\0’ 的数据的安全问题
- 提供了内存预分配机制,避免了频繁的内存分配:成倍扩容的机制,当一个字符扩容后说明这个字符可能还会用到,当再次需要加长度的时候可能就不需要再次扩容了,是一种空间换时间的体现
- 兼容c语言的函数库:它还会再buf[]后面追加 ‘\0’ 用来兼容c语言的字符标识
不同版本redis字符串设计原理区别
redis 3.2 以前
struct sdshdr {
int len;//这个buf的长度
int free;//buf剩余可用的长度
char buf[];//存储字符串的数组
};//比如buf总长度为8,buf[]="libo"使用了4个字节,len=4、free=8-4=4
struct sdshdr {
int len;//这个buf的长度
int free;//buf剩余可用的长度
char buf[];//存储字符串的数组
};//比如buf总长度为8,buf[]="libo"使用了4个字节,len=4、free=8-4=4
redis 3.2 后:
不同长度字符使用不同创建方式:在redis3.2之前不管长度为多少使用的都是int类型来表示buf长度,因为int类型为4个字节,最大可表示的长度为2^32 -1,往往很多字符串根本没有这么大的长度,很是浪费内存,所以在redis3.2之后采用的不同的长度使用不同的创建方法,比如sdshdr5表示当字符串长度小于2^5 -1时使用,sdshdr8表示当字符串长度小于2^8 -1时使用等,大大减少内存消耗
关于flags /flæɡ/ 说明:比如sdshdr5中,前面一个字节表示flags,后面的才是buf所占内存,这个flags占一个字节也就是8位,前面3位用来标识什么类型(到底是sdshdr5还是sdshdr8之类的),后面5位用来表示buf的长度,sdshdr5就是这么来的。这里还有内存对齐的概念:flags+bug长度为64的整数倍(一个缓存行在64位操作系统中也就是64字节,操作系统寻址最快的是64位的整数倍寻址)。redis在寻址buf的时候首先会往buf的指针往前移一个字节,就得到了flags的指针,也就知道了此buf是什么类型,还剩多少len等信息
不同长度字符使用不同创建方式:在redis3.2之前不管长度为多少使用的都是int类型来表示buf长度,因为int类型为4个字节,最大可表示的长度为2^32 -1,往往很多字符串根本没有这么大的长度,很是浪费内存,所以在redis3.2之后采用的不同的长度使用不同的创建方法,比如sdshdr5表示当字符串长度小于2^5 -1时使用,sdshdr8表示当字符串长度小于2^8 -1时使用等,大大减少内存消耗
关于flags /flæɡ/ 说明:比如sdshdr5中,前面一个字节表示flags,后面的才是buf所占内存,这个flags占一个字节也就是8位,前面3位用来标识什么类型(到底是sdshdr5还是sdshdr8之类的),后面5位用来表示buf的长度,sdshdr5就是这么来的。这里还有内存对齐的概念:flags+bug长度为64的整数倍(一个缓存行在64位操作系统中也就是64字节,操作系统寻址最快的是64位的整数倍寻址)。redis在寻址buf的时候首先会往buf的指针往前移一个字节,就得到了flags的指针,也就知道了此buf是什么类型,还剩多少len等信息
redis整体数据结构
redis数据库db(16个)
typedef struct redisDb {
dict *dict;//这个就是存放我们<k-v>的库
dict *expires;//过期时间的库
dict *blocking_keys;
dict *ready_keys;
dict *watched_keys;
int id;
long long avg_ttl;
unsigned long expires_cursor;
list *defrag_later;
} redisDb;
<k-v>DB数据结构dict
typedef struct dict {
dictType *type;
void *privdata;
dictht ht[2];//我们的hash桶,长度默认为4。这里有两个hash桶,一个是扩容前,一个是扩容后
long rehashidx;
unsigned long iterators;
} dict;
数据库类型dict:比如*dict指针存放的就是<k-v>的库,hash桶默认长度为4
rehash扩容:默认hash桶的长度为4,当元素个数等于hashTable桶的长度的时候触发扩容,直接扩容一倍大小。(也是有扩容因子的,跟java一样0.75)
扩容后数据迁移:数据迁移也不是在扩容后一次性迁移过去,而是渐进式扩容机制,当我们访问到这个key的时候才会把这个key对应的桶迁移到新hash桶里,当然也会有一个异步的线程去慢慢迁移。所以当我们访问一个key的时候,首先会去老hash桶寻找,然后再去新hash桶寻找。在redis的dict中定义了dictht ht[2];我们的hash桶,长度默认为4。这里有两个hash桶,一个是扩容前,一个是扩容后,迁移完后将h0指向新数组,释放老数组
扩容后数据迁移:数据迁移也不是在扩容后一次性迁移过去,而是渐进式扩容机制,当我们访问到这个key的时候才会把这个key对应的桶迁移到新hash桶里,当然也会有一个异步的线程去慢慢迁移。所以当我们访问一个key的时候,首先会去老hash桶寻找,然后再去新hash桶寻找。在redis的dict中定义了dictht ht[2];我们的hash桶,长度默认为4。这里有两个hash桶,一个是扩容前,一个是扩容后,迁移完后将h0指向新数组,释放老数组
hash桶数据结构dictht
typedef struct dictht {
dictEntry **table;//hash桶--hashtable
unsigned long size;//hash桶数组长度,也就是hash桶的长度
unsigned long sizemask;//size-1
unsigned long used;//元素的数量,而不是hash桶的长度
} dictht;
dictEntry **table;//hash桶--hashtable
unsigned long size;//hash桶数组长度,也就是hash桶的长度
unsigned long sizemask;//size-1
unsigned long used;//元素的数量,而不是hash桶的长度
} dictht;
<key-value>数据结构dictEntry
typedef struct dictEntry {
void *key;//指向sds类型的指针,也就是key,key为sds类型
union {//union 解释:c语言中,同一时间只会使用{}里的一种
void *val;//存放value的指针,指向的类型为redisObject
uint64_t u64;
int64_t s64;
double d;
} v;
struct dictEntry *next;//指向下一个dictEntry 的指针,用于hash冲突形成链表,采用头插法:后面插入的数据放到前面
} dictEntry;
value数据结构表示--redisObject
typedef struct redisObject {
unsigned type:4;//用来表示为什么类型的数据,比如是string、hash、list、set、zset 通过这个来约束客户端只能使用相应类型的命令
unsigned encoding:4;//redis针对数据底层的编码形式
unsigned lru:LRU_BITS;
int refcount;//redis采用引用计数法进行垃圾回收
void *ptr;//指向数据的指针,也可能就是数据本身,下面有讲解
} robj;
使用type查看数据是什么数据结构: type key
使用object encoding查看地城编码形式:object encoding key
使用object encoding查看地城编码形式:object encoding key
redis数据结构底层编码
string数据结构底层编码
长整型使用int类型编码:(这里说的长整形理解成long别被这个int搞混了)上面说了这个*ptr就是我们的数据指针,但是也有可能就是我们的数据,为什么这么说呢?首先*ptr占用8个字节,而我们的长整型固定也是8个字节,那如果是整形数据是不是可以直接存储在*ptr中呢?当然是可以,redis也是这么做的,这么做的好处除了减少内存消耗还减少了内存寻址io(指针指向数据地址读取时需要寻址)
低于44字节使用embstr类型编码:在64位操作系统上,每次寻址都是读取一个缓存行,缓存行的大小为64byte(字节),然后我们看下redisObject 才占用16个字节(看上面redisObject代码),那缓存行后面的内存就使用不到了岂不是很浪费?还剩64byte-16byte=48byte,redis当然是需要利用起来的。是不是可以这样:前面16个字节存放RedisObject,然后后面48个字节就直接放我们的数据,这样cpu读取一个缓存行就直接读取到了数据,不用再次读取,同时也减少内存浪费。
关于为什么是44个字节解释:如果type为string类型的,48byte存放的话使用的是前面说的sdshdr8(sdshdr5是放不下的,sdshdr5能表示字节的长度范围为0-31【2^5-1】),然后我们看sdshdr8除了数据外还需占用4字节,那么真正存放数据的 buf[] 可用内存为44字节
关于为什么是44个字节解释:如果type为string类型的,48byte存放的话使用的是前面说的sdshdr8(sdshdr5是放不下的,sdshdr5能表示字节的长度范围为0-31【2^5-1】),然后我们看sdshdr8除了数据外还需占用4字节,那么真正存放数据的 buf[] 可用内存为44字节
高于44字节使用raw:高于的话这个*ptr只能是指向数据的指针了
List数据结构底层编码
List是一个有序(按加入的时序排序)的数据结构,Redis采用quicklist(双端链表) 和 ziplist 作为List的底层实现
ziplist(压缩列表)结构详解
为什么要有ziplist
- 普通的双向链表,会有两个指针,在存储数据很小的情况下,我们存储的实际数据的大小可能还没有指针占用的内存大,是不是有点得不偿失?而且Redis是基于内存的,而且是常驻内存的,内存是十分珍贵的,所以Redis的开发者们肯定要使出浑身解数优化占用内存,于是,ziplist出现了。
- 链表在内存中,一般是不连续的,遍历相对比较慢,而ziplist可以很好的解决这个问题
ziplist结构
- zlbytes:32bit,表示ziplist占用的字节总数。
- zltail:32bit,表示ziplist表中最后一项(entry)在ziplist中的偏移字节数。通过zltail我们可以很方便地找到最后一项,从而可以在ziplist尾端快速地执行push或pop操作,保证了时间复杂度为O(1)
- zlen:16bit, 表示ziplist中数据项(entry)的个数。
- entry:表示真正存放数据的数据项,长度不定
- zlend: ziplist最后1个字节,是一个结束标记,值固定等于255
entry的构成
从ziplist布局中,我们可以很清楚的知道,我们的数据被保存在ziplist中的一个个entry中,我们下面来看看entry的构成。
前一个元素的长度小于254字节时,prevlen用1个字节表示;
前一个元素的长度大于等于254字节时,prevlen用5个字节进行表示,此时,prevlen的第一个字节是固定的254(0xFE)(作为这种情况的一个标志),后面4个字节才表示前一个元素的长度。
从ziplist布局中,我们可以很清楚的知道,我们的数据被保存在ziplist中的一个个entry中,我们下面来看看entry的构成。
- prerawlen: 前一个元素的字节长度,便于快速找到前一个元素的首地址,假如当前元素的首地址是x,那么(x-prevlen)就是前一个元素的首地址。
前一个元素的长度小于254字节时,prevlen用1个字节表示;
前一个元素的长度大于等于254字节时,prevlen用5个字节进行表示,此时,prevlen的第一个字节是固定的254(0xFE)(作为这种情况的一个标志),后面4个字节才表示前一个元素的长度。
- len: entry中数据的长度:Redis根据len字段的前两位来判断存储的数据是字符串(字节数组)还是整型(len值为11xxxx的都是整形)
如果是字符串,还可以通过len字段的前两位来判断字符串的长度范围,前两位后面的位数得到字符串长度;如果是整形,则要通过后面的位来判断具体长度 - data: 真实数据存储
为什么不能一直是ziplist
因为ziplist是紧凑存储,没有冗余空间,意味着新插入元素,就需要扩展内存,这就分为两种情况:
- 分配新的内存,将原数据拷贝到新内存;
- 扩展原有内存:追加内存。
ziplist存储界限
那么满足什么条件后,zset、hash的底层存储结构不再是ziplist呢?
- hash-max-ziplist-entries 512 # hash 的元素个数超过 512 就必须用标准结构存储
- hash-max-ziplist-value 64 # hash 的任意元素的 key/value 的长度超过 64 就必须用标准结构存储
- zset-max-ziplist-entries 128 # zset 的元素个数超过 128 就必须用标准结构存储
- zset-max-ziplist-value 64 # zset 的任意元素的长度超过 64 就必须用标准结构存储
List使用quicklist编码
redis的List结构使用的是分成多块ziplist的quicklist结构:quicklist是一个双向链表,而且是一个元素为ziplist的双向链表。quicklist的每个节点都是一个ziplist。ziplist本身也是一个有序列表,而且是一个内存紧缩的列表(各个数据项在内存上前后相邻)。比如,一个包含3个节点的quicklist,如果每个节点的ziplist又包含4个数据项,那么对外表现上,这个list就总共包含12个数据项
quicklist的结构为什么这样设计呢?总结起来,大概又是一个空间和时间的折中:
- 双向链表便于在表的两端进行push和pop操作,但是它的内存开销比较大。首先,它在每个节点上除了要保存数据之外,还要额外保存两个指针(虽然说quicklist也有两个指针,但是并不是像普通双向链表那样每个元素都有两个指向前后元素的指针,这里的元素为ziplist,而ziplist里包含多个数据,简单说就是虽然还有指向前后的指针,但是数量大大减少);其次,双向链表的各个节点是单独的内存块,地址不连续,节点多了容易产生内存碎片。
- ziplist由于是一整块连续内存,所以存储效率很高。但是,它不利于修改操作,每次数据变动都会引发一次内存的realloc(重新分配)。特别是当ziplist长度很长的时候,一次realloc可能会导致大批量的数据拷贝,进一步降低性能
到底一个quicklist节点包含多长的ziplist合适呢?
比如,同样是存储12个数据项,既可以是一个quicklist包含3个节点,而每个节点的ziplist又包含4个数据项,也可以是一个quicklist包含6个节点,而每个节点的ziplist又包含2个数据项。
这又是一个需要找平衡点的难题。我们只从存储效率上分析一下:
可见,一个quicklist节点上的ziplist要保持一个合理的长度。那到底多长合理呢?这可能取决于具体应用场景。实际上,Redis提供了一个配置参数list-max-ziplist-size,就是为了让使用者可以来根据自己的情况进行调整。
可以通过设置每个ziplist的最大容量,quicklist的数据压缩范围,提升数据存取效率
比如,同样是存储12个数据项,既可以是一个quicklist包含3个节点,而每个节点的ziplist又包含4个数据项,也可以是一个quicklist包含6个节点,而每个节点的ziplist又包含2个数据项。
这又是一个需要找平衡点的难题。我们只从存储效率上分析一下:
- 每个quicklist节点上的ziplist越短,则内存碎片越多。内存碎片多了,有可能在内存中产生很多无法被利用的小碎片,从而降低存储效率。这种情况的极端是每个quicklist节点上的ziplist只包含一个数据项,这就蜕化成一个普通的双向链表了。
- 每个quicklist节点上的ziplist越长,则为ziplist分配大块连续内存空间的难度就越大。有可能出现内存里有很多小块的空闲空间(它们加起来很多),但却找不到一块足够大的空闲空间分配给ziplist的情况。这同样会降低存储效率。这种情况的极端是整个quicklist只有一个节点,所有的数据项都分配在这仅有的一个节点的ziplist里面。这其实蜕化成一个ziplist了。
可见,一个quicklist节点上的ziplist要保持一个合理的长度。那到底多长合理呢?这可能取决于具体应用场景。实际上,Redis提供了一个配置参数list-max-ziplist-size,就是为了让使用者可以来根据自己的情况进行调整。
可以通过设置每个ziplist的最大容量,quicklist的数据压缩范围,提升数据存取效率
hash数据结构底层编码
Hash 数据结构底层实现为一个字典( dict ),也是Redis Db用来存储K-V的数据结构,当数据量比较小,且单个元素比较小时,底层用ziplist存储,数据大小和元素数量阈值,使用ziplist的时候其实是有序的
可以通过参数设置
以下都是redis默认设置:
hash-max-ziplist-entries 512 // ziplist 元素个数超过 512 ,将改为hashtable编码
hash-max-ziplist-value 64 // 单个元素大小超过 64 byte时,将改为hashtable编码
可以通过参数设置
以下都是redis默认设置:
hash-max-ziplist-entries 512 // ziplist 元素个数超过 512 ,将改为hashtable编码
hash-max-ziplist-value 64 // 单个元素大小超过 64 byte时,将改为hashtable编码
使用ziplist编码
当使用ziplist编码的时候,底层数据结构如下图,是前一个为key,后一个为value的方式存储。ziplist是连续的内存,所以对我们计算机寻址来说是友好的,这也是为什么hash当数据量少的时候采用ziplist的编码原因,但是当我们的数据量太大或者单个value太长,就不方便用ziplist了,会转变成hashtable
为什么hash比string做缓存更节省内存与效率更高?首先前提是在hash使用ziplist编码的情况。首先我们存入的是多个缓存,每个sds都需要包含len(已用长度)、alloc(buf[]分配长度)、flags(标识),bug[],而在ziplist中只需要前面几个prerawlen(前一个元素的字节长度)、len(entry中数据的长度)、接着就是紧凑的数据了,这样在多个字符串存储的时候ziplist就省去了大量的数据外的空间占用,这就是省内存的关键。那为什么效率为何更高?正是因为空间占用的少,所以寻址的次数就会更少,效率也就更高。
使用hashtable编码
底层使用hashtable编码的时候,对应c源码就是前面说的dict。
关于数据量多string跟hash的区别?比如我们用来存对象,当使用string类型的时候,数据量越来越大的时候,会导致hash桶不够用从而造成rehash。但是如果用hash的结构就不会,虽然数据也多,但是多的是我们内部的hash桶,并不会造成外部hash桶的rehash,尽管也会造成内部的rehash
关于数据量多string跟hash的区别?比如我们用来存对象,当使用string类型的时候,数据量越来越大的时候,会导致hash桶不够用从而造成rehash。但是如果用hash的结构就不会,虽然数据也多,但是多的是我们内部的hash桶,并不会造成外部hash桶的rehash,尽管也会造成内部的rehash
set数据结构底层编码
Set 为无序的,自动去重的集合数据类型,Set 数据结构底层实现为一个value 为 null 的 字典( dict ),当数据可以用整形表示时,Set集合将被编码为intset数据结构。以下两个条件任意满足时
Set将用hashtable存储数据:
Set将用hashtable存储数据:
- 1, 元素个数大于 set-max-intset-entries ,
- 2 , 元素无法用整形表示
set-max-intset-entries 512 // intset 能存储的最大元素个数,超过则用hashtable编码
使用intset编码
整数集合是一个有序的,存储整型数据的结构。整型集合在Redis中可以保存int16_t,int32_t,int64_t类型的整型数据,并且可以保证集合中不会出现重复数据。存储的结构就是一个数组
使用hashtable存储
当我们加入一个非整形的数据后,变成无序,编码也变成了hashtable(一个value 为 null 的 字典dict)
zset数据结构底层编码
ZSet 为有序的,自动去重的集合数据类型,ZSet 数据结构底层实现为字典(dict) + 跳表(skiplist) ,当数据比较少且元素大小未达到阈值时,用ziplist编码结构存储
使用ziplist编码
当数据比较少且元素大小未达到阈值时,用ziplist编码结构存储,分数在后,value在前,如图
使用字典(dict) + 跳表(skiplist)编码
可以通过如下两个参数设置升级跳表编码阈值:
- zset-max-ziplist-entries 128 // 元素个数超过128 ,将用skiplist编码
- zset-max-ziplist-value 64 // 单个元素大小超过 64 byte, 将用 skiplist编码
先了解下什么是跳表:
有点类似我们的B+树,最下层的是数据,上面的都是索引。比如当我们查找值为73的值时,首先会从顶层索引开始找到比73大的第一个数,因为除了62已经没有了,所以这里找到了62,然后继续往下一层索引层找,一样的找法,找到78,然后就知道73在62-78这个范围,再继续往下一层索引找,依次内推。查找采用二分法。整个查询时间复杂度为O(log(n)),n为索引高度
有点类似我们的B+树,最下层的是数据,上面的都是索引。比如当我们查找值为73的值时,首先会从顶层索引开始找到比73大的第一个数,因为除了62已经没有了,所以这里找到了62,然后继续往下一层索引层找,一样的找法,找到78,然后就知道73在62-78这个范围,再继续往下一层索引找,依次内推。查找采用二分法。整个查询时间复杂度为O(log(n)),n为索引高度
然后再看下redis中zset是怎么做的:
字典(dict) + 跳表(skiplist)实现zset,当我们查询数据的时候,首先会根据key到dict中查询数据的分数<元素,score>,skiplist用来根据分数查询数据(skiplist作为跳跃表,按照分值排序,方便定位成员)
字典(dict) + 跳表(skiplist)实现zset,当我们查询数据的时候,首先会根据key到dict中查询数据的分数<元素,score>,skiplist用来根据分数查询数据(skiplist作为跳跃表,按照分值排序,方便定位成员)
Redis 6.0 新特性
多线程
redis 6.0 之前线程执行模式
redis 6.0 以前线程执行模式,如下操作在一个线程中执行完成:read(读取命令)-->parse解析命令-->command execute(执行命令)-->write(响应客户端)
redis 6.0 线程执行模式
可以通过如下参数配置多线程模型(默认都是不开启的):如:
io-threads 4 // 这里说 有三个IO 线程,还有一个线程是main线程,main线程负责IO读写和命令执行操作
默认情况下,如上配置,有三个IO线程, 这三个IO线程只会执行 IO中的write 操作,也就是说,read 和 命令执行 都由main线程执行。最后写回客户端才是多线程执行
io-threads 4 // 这里说 有三个IO 线程,还有一个线程是main线程,main线程负责IO读写和命令执行操作
默认情况下,如上配置,有三个IO线程, 这三个IO线程只会执行 IO中的write 操作,也就是说,read 和 命令执行 都由main线程执行。最后写回客户端才是多线程执行
如想多线程执行命令步骤需要开启如下配置,同样执行命令的操作还是只有main线程完成【多线程读命令、解析命令、写回结果,单线程执行命令】
开启如下参数将支持IO线程执行 读写任务:
io-threads-do-reads yes // 将支持IO线程执行 读写任务。
开启如下参数将支持IO线程执行 读写任务:
io-threads-do-reads yes // 将支持IO线程执行 读写任务。
client side caching(客户端缓存)
客户端缓存:redis 6 提供了服务端追踪key的变化,客户端缓存数据的特性,这需要客户端实现
当客户端访问某个key时,服务端将记录key 和 client ,客户端拿到数据后,进行客户端缓存,这时,当key再次被访问时,key将被直接返回,避免了与redis 服务器的再次交互,节省服务端资源,当数据被其他请求修改时,服务端将主动通知客户端失效的key,客户端进行本地失效,下次请求时,重新获取最新数据
ACL命令的访问和执行权限控制
ACL 是对于命令的访问和执行权限的控制,默认情况下,可以有执行任意指令的权限,兼容以前版本
ACL设置有两种方式:
1. 命令方式 ACL SETUSER + 具体的权限规则, 通过 ACL SAVE 进行持久化
2. 对 ACL 配置文件进行编写,并且执行 ACL LOAD 进行加载
ACL存储有两种方式,但是两种方式不能同时配置,否则直接报错退出进程
1.redis 配置文件: redis.conf
2.ACL配置文件, 在redis.conf 中通过 aclfile /path 配置acl文件的路径
1.redis 配置文件: redis.conf
2.ACL配置文件, 在redis.conf 中通过 aclfile /path 配置acl文件的路径
redis实现分布式锁
使用synchronized实现加锁
存在的问题:先不说这个synchronize是重锁,效率不是很好,虽然这个在单机环境下是没有问题的,毕竟同一时刻只有一个线程能进入代码执行。但是如果在分布式场景呢,就存在问题了。因为synchronized 只能作用于当前jvm。
使用redis实现分布式锁
初代使用redis实现分布式锁
- SET product:10001 true
我们知道redis存在一个命令setnx key value ,若给定的 key 已经存在,则 SETNX 不做任何动作且返回0。因为redis是单线程的所以我们可以利用这点实现一个分布式锁:当设置成功时我才去执行业务代码,设置失败则不给执行,业务代码执行完后再删除锁即可
public String deduct_stock_redis() {
String lock = "product:001";
//尝试获取锁
Boolean ifAbsent = stringRedisTemplate.opsForValue().setIfAbsent(lock, "lock");
if (!ifAbsent) {
return "服务器繁忙,请稍后再试!";
}
//----------执行业务
//释放锁
stringRedisTemplate.delete(lock);
}
public String deduct_stock_redis() {
String lock = "product:001";
//尝试获取锁
Boolean ifAbsent = stringRedisTemplate.opsForValue().setIfAbsent(lock, "lock");
if (!ifAbsent) {
return "服务器繁忙,请稍后再试!";
}
//----------执行业务
//释放锁
stringRedisTemplate.delete(lock);
}
存在问题的:
- 1、如果说在执行业务代码出了异常的时候,那么这个锁永远得不到释放造成其他线程永远无法进入业务代码;解决办法:可以通过try起来,在finally中释放锁,这样就算出异常也会释放锁
- 2、如果在执行业务代码的时候宕机了呢(比如被运维关了),锁永远释放不了,就算try起来也没用?解决办法:加上过期时间,到了一定时间自动释放锁
- 3、如果在加锁后,加过期时间的时候宕机了呢?因为过期时间没有加上导致锁永远释放不了?解决办法:保证加锁与设置过期时间为原子操作,使用SET product:10001 true ex 10 nx
锁加上过期时间版本(一般公司基本够用)
- SET product:10001 true ex 10 nx
public String deduct_stock_redis() {
String lock = "product:001";
//尝试获取锁,获取成功添加过期时间
Boolean ifAbsent = stringRedisTemplate.opsForValue().setIfAbsent(lock, "lock",10, TimeUnit.SECONDS);
if (!ifAbsent) {
return "服务器繁忙,请稍后再试!";
}
try {
//----------执行业务
} finally {
//释放锁
stringRedisTemplate.delete(lock);
}
}
String lock = "product:001";
//尝试获取锁,获取成功添加过期时间
Boolean ifAbsent = stringRedisTemplate.opsForValue().setIfAbsent(lock, "lock",10, TimeUnit.SECONDS);
if (!ifAbsent) {
return "服务器繁忙,请稍后再试!";
}
try {
//----------执行业务
} finally {
//释放锁
stringRedisTemplate.delete(lock);
}
}
锁被别的线程释放的问题分析:这里我们设置了锁过期时间为10s,比如存在这么一种情况:
1:T1线程拿到锁并且设置过期时间为10s,此时执行业务代码,但是由于某种原因,业务代码需要执行15s;
2:T1设置好锁的过期时间后,redis那边会在10s后释放锁,但是此时T1线程却还没有执行结束,而且因为锁到了过期时间被redis删除了,所以此时T2线程可以获取到锁了;
3:接着T1线程执行完,准备删除锁,但是这个锁却是T2线程加的,这样T1就把T2线程的锁给删除了。在高并发场景下导致锁跟没加一样,达不到我们想要的效果,这就是问题!!!
当然同时也存在业务执行时间大于锁过期时间导致的问题
1:T1线程拿到锁并且设置过期时间为10s,此时执行业务代码,但是由于某种原因,业务代码需要执行15s;
2:T1设置好锁的过期时间后,redis那边会在10s后释放锁,但是此时T1线程却还没有执行结束,而且因为锁到了过期时间被redis删除了,所以此时T2线程可以获取到锁了;
3:接着T1线程执行完,准备删除锁,但是这个锁却是T2线程加的,这样T1就把T2线程的锁给删除了。在高并发场景下导致锁跟没加一样,达不到我们想要的效果,这就是问题!!!
当然同时也存在业务执行时间大于锁过期时间导致的问题
解决办法:解决这个问题也简单,因为是删除了别的线程的锁,那我们可以每次在删除的时候判断下即可,如果是自己的锁就删除,否者不给删除。
防止释放别的线程加的锁版本
通过UUID保证只能释放自己线程加的锁:
public String deduct_stock_redis() {
String lock = "product:001";
String clientId = UUID.randomUUID().toString();
//尝试获取锁,获取成功添加过期时间
Boolean ifAbsent = stringRedisTemplate.opsForValue().setIfAbsent(lock, clientId,10, TimeUnit.SECONDS);
if (!ifAbsent) {
return "服务器繁忙,请稍后再试!";
}
try {
//----------执行业务
} finally {
//释放锁时判断是否为自己的锁
if(clientId.equals(stringRedisTemplate.opsForValue().get(lock))){
//释放锁
stringRedisTemplate.delete(lock);
}
}
}
public String deduct_stock_redis() {
String lock = "product:001";
String clientId = UUID.randomUUID().toString();
//尝试获取锁,获取成功添加过期时间
Boolean ifAbsent = stringRedisTemplate.opsForValue().setIfAbsent(lock, clientId,10, TimeUnit.SECONDS);
if (!ifAbsent) {
return "服务器繁忙,请稍后再试!";
}
try {
//----------执行业务
} finally {
//释放锁时判断是否为自己的锁
if(clientId.equals(stringRedisTemplate.opsForValue().get(lock))){
//释放锁
stringRedisTemplate.delete(lock);
}
}
}
关于锁过期时间设置多少的权衡:其实这个过期时间设置多少都不好说,因为你也不知道你的业务代码会不会因为某种原因导致长时间没执行完,设置太长又不好,万一加锁的那台服务挂了不主动释放锁了(锁续命的时候不存在这个问题,当加锁的服务器挂了续命线程也挂了,自然会自动释放锁),其他线程就只能长时间等待redis锁过期时间到来。设置太短呢又会加大业务代码没执行完锁就过期的可能性,所以这个东西真的不好说
存在问题分析:比如上面设置锁过期时间为10s的情况,T1线程也确实是要执行15s的时候。到了10s的时候锁过期释放了,这时候T2线程获取锁且已经进来了。这其实就已经是存在问题了,因为没有保证同一时候只有一个线程在执行业务操作。比如T1在执行代码扣减库存前锁已过期且T2线程获取到锁,这个时候T2线程获取的库存跟T1线程获取的库存是一样的,也就导致了超卖
锁续命(看门狗)伪代码
通过锁续命解决:我们可以在加锁后创建一个定时任务线程,每隔多久去查看下业务线程的锁是否还在,如果不在了就给锁续命。比如我设置锁过期时间为30s,然后在线程加锁后开启一个定时任务线程,每隔10s去检查下业务线程的锁是否还存在(线程没主动释放的情况下锁是不可能不存在的,因为会续命),如果存在则续命30s。这样就可以很好的解决锁自动释放但是业务尚未执行完的问题
//----------------------------------开启定时线程检查锁是否过期,如过期则续命一波 伪代码
//判断锁是否存在
if(距离上次检查时间是否达到10s && redis.get(lock) != null){
//存在重新加锁也就是续命
redis.setnx(lock, clientId,30, TimeUnit.SECONDS);
}else{
//不存在的情况只有是锁是线程释放的情况,直接结束看门狗线程
}
看门狗这个代码如果自己去写的话很容易就出问题,所以不建议自己去写。可以使用Redisson
//判断锁是否存在
if(距离上次检查时间是否达到10s && redis.get(lock) != null){
//存在重新加锁也就是续命
redis.setnx(lock, clientId,30, TimeUnit.SECONDS);
}else{
//不存在的情况只有是锁是线程释放的情况,直接结束看门狗线程
}
看门狗这个代码如果自己去写的话很容易就出问题,所以不建议自己去写。可以使用Redisson
Redisson实现分布式锁
redisson说白了也是跟Jedis一样是一个redis客户端,而redisson更多的是提供一些分布式场景下的api,比如分布式锁、读写锁、布隆过滤器 https://redisson.org/
redisson原理分析
其实跟我们上边看门狗的伪代码原理是差不多的,只是说它考虑的比较多。而且也提供了很多的方案。比如自旋锁(当然是等到过期时间到了在自旋,也就是下图的while循环那部分)、重入锁(每次重入自加1)。默认是30s的过期时间以及看门狗每10秒执行一次续命判断
redisson原理分析:线程1跟线程2同时去抢锁,redisson一样的通过redis的setnx来控制同时只有一个线程获取锁成功也就是串行化执行,而且会开启一个子线程(看门狗)每隔一段时间检查是持有锁,如果持有则延长锁的时间,这里要注意的是这个锁不可能不在的,如果不在那就有问题了的,为什么锁肯定存在呢?比如这里锁是30s过期,然后看门狗每10s会重新续命一次,所以说这个锁肯定都是在的。redisson里面大量使用到了lua脚本保证redis执行的原子性,如果执行lock.unlock(),就可以释放分布式锁,此时的业务逻辑也是非常简单的。其实说白了,就是每次都对myLock数据结构中的那个加锁次数减1。【加锁加一,解锁减一】
如果发现加锁次数是0了,说明这个客户端已经不再持有锁了,此时就会用:“del myLock”命令,从redis里删除这个key
重入说的是我们再次调用lock.lock()的时候,如果是同一个线程可以直接进入,并且加锁次数加一
如果发现加锁次数是0了,说明这个客户端已经不再持有锁了,此时就会用:“del myLock”命令,从redis里删除这个key
重入说的是我们再次调用lock.lock()的时候,如果是同一个线程可以直接进入,并且加锁次数加一
redisson存在的问题以及解决方案
其实上面那种方案最大的问题,就是如果你对某个redis master实例,写入了myLock这种锁key的value,此时会异步复制给对应的master slave实例。但是这个过程中一旦发生redis master宕机,主备切换,redis slave变为了redis master。接着就会导致,客户端2来尝试加锁的时候,在新的redis master上完成了加锁,而客户端1也以为自己成功加了锁(说白了就是锁丢失)。此时就会导致多个客户端对一个分布式锁完成了加锁。这时系统在业务语义上一定会出现问题,导致各种脏数据的产生。所以这个就是redis cluster,或者是redis master-slave架构的主从异步复制导致的redis分布式锁的最大缺陷:在redis master实例宕机的时候,可能导致多个客户端同时完成加锁导致串行控制失败
使用zookeeper解决:这种情况其实对于redis来说是无法完美的解决的,这都是根据业务角度是否可容忍。如果实在是不可容忍,那就得用zookeeper,zookeeper强一致保证了主从中的数据同步(至少半数以上的slave节点同步成功才算成功,在master节点挂了会保证选的新master数据肯定有的),但是呢,效率肯定是会大打折扣的,所以都得结合业务场景去权衡【redis主从中,主节点把命令写入缓存就不管了,至于从节点是否同步成功无法影响到主节点】
使用redlock解决(不推荐):它的实现原理其实跟zookeeper的强一致同步原理差不多,也是半数以上同步成功才算成功,但是基本上没啥公司去用。毕竟这东西很有可能存在问题,先不说效率问题,比如同步如果失败了呢?那是不是得回滚已经设置成功的redis节点?是不是这又可能存在数据不一致的问题?这样一个问题一个问题的引发与解决,就确保 没有问题吗?又不是zookeeper这种迭代了这么久的中间件
高并发分布式锁怎么实现
比如我大促前需要增加10倍并发量,该怎么办呢?毕竟一台redis的并发是有瓶颈的
不同商品:如果是不同的商品,比如双十一的时候,会有很多的商品并发同时会扩大很多倍,这个时候我们可以通过加机器来解决,为什么这么说呢?每个商品通过redis-cluster后都是在不同的redis节点上,只要我分布的大了,自然平均下来每个节点承受的并发就不会超过瓶颈。
相同商品:上面不同的商品可以通过hash路由到不同的redis节点中,平摊并发,那么如果是同一个商品呢?按照我们之前的设计,根据商品id不同hash路由到不同的节点去,那要是相同的商品id是不是都路由到同一个节点,再怎么优化一个节点的处理是有瓶颈的,那怎么办?此时我们可以使用分段锁设计理念,就类似concurrentHashMap一样,我可以把库存分成好几段,比如想扩大10倍处理能力,那我大可以把库存分成10段(比如1000库存分为1-100,100-200.....),分别存储到不同的redis中去。首先客户端先通过hash算法将请求打到不同段的节点去,如果路由的节点库存已经卖完了,我可以重新hash一次直到路由到存在库存的节点去,如果都不存在了那说明库存已经空了
不同商品:如果是不同的商品,比如双十一的时候,会有很多的商品并发同时会扩大很多倍,这个时候我们可以通过加机器来解决,为什么这么说呢?每个商品通过redis-cluster后都是在不同的redis节点上,只要我分布的大了,自然平均下来每个节点承受的并发就不会超过瓶颈。
相同商品:上面不同的商品可以通过hash路由到不同的redis节点中,平摊并发,那么如果是同一个商品呢?按照我们之前的设计,根据商品id不同hash路由到不同的节点去,那要是相同的商品id是不是都路由到同一个节点,再怎么优化一个节点的处理是有瓶颈的,那怎么办?此时我们可以使用分段锁设计理念,就类似concurrentHashMap一样,我可以把库存分成好几段,比如想扩大10倍处理能力,那我大可以把库存分成10段(比如1000库存分为1-100,100-200.....),分别存储到不同的redis中去。首先客户端先通过hash算法将请求打到不同段的节点去,如果路由的节点库存已经卖完了,我可以重新hash一次直到路由到存在库存的节点去,如果都不存在了那说明库存已经空了
Redis缓存设计与性能优化
redis缓存设计
缓存穿透
缓存穿透问题
缓存穿透是指查询一个根本不存在的数据, 缓存层和存储层都不会命中, 通常出于容错的考虑, 如果从存储层查不到数据则不写入缓存层。
缓存穿透将导致不存在的数据每次请求都要到存储层去查询, 失去了缓存保护后端存储的意义。当有人恶意攻击的时候可能会压垮数据库
缓存穿透将导致不存在的数据每次请求都要到存储层去查询, 失去了缓存保护后端存储的意义。当有人恶意攻击的时候可能会压垮数据库
造成缓存穿透的基本原因有两个:
第一, 自身业务代码或者数据出现问题。
第二, 一些恶意攻击、 爬虫等造成大量空命中
第一, 自身业务代码或者数据出现问题。
第二, 一些恶意攻击、 爬虫等造成大量空命中
解决方案
缓存空对象:如果数据库中查询不到,也设置缓存中去,只不过设置值为null,但是注意这里需要设置过期时间,防止过段时间确实是有了这个数据而永远查不到。
布隆过滤器
对于恶意攻击,向服务器请求大量不存在的数据造成的缓存穿透,还可以用布隆过滤器先做一次过滤,对于不存在的数据布隆过滤器一般都能够过滤掉,不让请求再往后端发送。当布隆过滤器说某个值存在时,这个值可能不存在;当它说不存在时,那就肯定不存在
布隆过滤器原理:布隆过滤器就是一个大型的位数组和几个不一样的无偏 hash 函数。所谓无偏就是能够把元素的 hash 值算得比较均匀
add操作:向布隆过滤器中添加 key 时,会使用多个 hash 函数对 key 进行 hash 算得一个整数索引值然后对位数组长度进行取模运算得到一个位置,每个 hash 函数都会算得一个不同的位置。再把位数组的这几个位置都置为 1 就完成了 add 操作。
检查是否存在操作:向布隆过滤器询问 key 是否存在时,跟 add 一样,也会把 hash 的几个位置都算出来,看看位数组中这几个位置是否都为 1,只要有一个位为 0,那么说明布隆过滤器中这个key 不存在。如果都是 1,这并不能说明这个 key 就一定存在,只是极有可能存在,因为这些位被置为 1 可能是因为其它的 key 存在所致。如果这个位数组比较稀疏,这个key存在概率就会很大,如果这个位数组比较拥挤,这个概率就会降低
检查是否存在操作:向布隆过滤器询问 key 是否存在时,跟 add 一样,也会把 hash 的几个位置都算出来,看看位数组中这几个位置是否都为 1,只要有一个位为 0,那么说明布隆过滤器中这个key 不存在。如果都是 1,这并不能说明这个 key 就一定存在,只是极有可能存在,因为这些位被置为 1 可能是因为其它的 key 存在所致。如果这个位数组比较稀疏,这个key存在概率就会很大,如果这个位数组比较拥挤,这个概率就会降低
适用场景:这种方法适用于数据命中不高(毕竟布隆过滤器说存在的时候这个key也可能不存在)、 数据相对固定(数据经常变动的就没必要使用布隆过滤器了,倒不如直接落DB,可能直接落DB比维护缓存的代价小的多,布隆过滤器不支持删除)、 实时性低(通常是数据集较大) 的应用场景(毕竟实时性高的话那么同步到布隆过滤器的实时性也需要高), 代码维护较为复杂, 但是缓存空间占用很少
使用布隆过滤器需要把所有数据提前放入布隆过滤器,并且在增加数据时(修改就不需要)也要往布隆过滤器里放
注意:布隆过滤器不能删除数据,如果要删除得重新初始化数据
因为布隆过滤器说这个值存在也未必是真的存在,所以可以结合第一种情况再次防止穿透
缓存失效(击穿)
缓存失效(击穿)问题
由于大批量缓存在同一时间失效可能导致大量请求同时穿透缓存直达数据库(就像秒杀场景,可能运营会批量一次性把很多商品加入秒杀商品页中去,此时如果我们设置的过期时间一样就存在问题了),可能会造成数据库瞬间压力过大甚至挂掉,对于这种情况我们在批量增加缓存时最好将这一批数据的缓存过期时间设置为一个时间段内的不同时间(随机时间)
解决方案:设置随机过期时间解决
缓存雪崩
缓存雪崩问题
缓存雪崩指的是缓存层支撑不住或宕掉后, 流量会像奔逃的野牛一样, 打向后端存储层
由于缓存层承载着大量请求, 有效地保护了存储层, 但是如果缓存层由于某些原因不能提供服务(比如超大并发过来,缓存层支撑不住,或者由于缓存设计不好,类似大量请求访问bigkey,导致缓存能支撑的并发急剧下降), 于是大量请求都会打到存储层, 存储层的调用量会暴增, 造成存储层也会级联宕机的情况
由于缓存层承载着大量请求, 有效地保护了存储层, 但是如果缓存层由于某些原因不能提供服务(比如超大并发过来,缓存层支撑不住,或者由于缓存设计不好,类似大量请求访问bigkey,导致缓存能支撑的并发急剧下降), 于是大量请求都会打到存储层, 存储层的调用量会暴增, 造成存储层也会级联宕机的情况
预防和解决缓存雪崩方案
- 1) 保证缓存层服务高可用性,比如使用Redis Sentinel或Redis Cluster。
- 2) 依赖隔离组件为后端限流熔断并降级。比如使用Sentinel或Hystrix限流降级组件。
- 比如服务降级,我们可以针对不同的数据采取不同的处理方式(有些业务场景是不太能接受降级处理的,所以最好还是分业务场景)。当业务应用访问的是非核心数据(例如电商商品属性,用户信息等,这种信息就算一开始显示为空影响也不会很大)时,暂时停止从缓存中查询这些数据,而是直接返回预定义的默认降级信息、空值或是错误提示信息;当业务应用访问的是核心数据(例如电商商品库存)时,仍然允许查询缓存,如果缓存缺失,也可以继续通过数据库读取。
- 3) 提前演练。 在项目上线前, 演练缓存层宕掉后, 应用以及后端的负载情况以及可能出现的问题, 在此基础上做一些预案设定。
热点缓存key重建优化
热点缓存与缓存重建问题
开发人员使用“缓存+过期时间”的策略既可以加速数据读写, 又保证数据的定期更新, 这种模式基本能够满足绝大部分需求。 但是有两个问题如果同时出现, 可能就会对应用造成致命的危害:
- 当前key是一个热点key(例如一个热门的娱乐新闻),导致并发量突然暴增。
- 重建缓存不能在短时间完成, 可能是一个复杂计算, 例如复杂的SQL、 多次IO、 多个依赖等
使用互斥锁解决
在缓存失效的瞬间, 有大量线程来重建缓存, 造成后端负载加大, 甚至可能会让应用崩溃。要解决这个问题主要就是要避免大量线程同时重建缓存。我们可以利用互斥锁来解决,此方法只允许一个线程重建缓存, 其他线程等待重建缓存的线程执行完, 重新从缓存获取数据即可。
缓存与数据库数据不一致
在大并发下,同时操作数据库与缓存会存在数据不一致性问题,先来看看两种造成数据库与缓存不一致的情况
双写不一致问题
解释:最左边是时间轴,右边表示有两个线程,线程1比线程2先执行写数据库,也就是需要先更新缓存,但是因为某种原因导致线程2先更新缓存数据,这样就存在了问题:本来数据最终应该是线程2的数据,但是因为线程1更新缓存在线程2更新缓存之后,导致了数据错了
读写并发不一致问题
解释:最左边是时间轴,右边表示有三个线程,当我们新增一个数据的时候会去删除缓存(或者更新缓存),这里以删除缓存这种情况说明。首先线程1写数据跟删除数据后,线程3查询到缓存为空,接着就会去查询数据库并更新缓存,但是在更新缓存时因为某种原因迟迟没有更新,此时线程2又去写数据并且删除了缓存,但是线程3准备更新缓存的数据又不是最新的数据,当线程3将数据更新至缓存后,此时数据已经是错误的数据了,也就是数据库与缓存不一种的情况
解决方案
1、对于并发几率很小的数据(如个人维度的订单数据、用户数据等),这种几乎不用考虑这个问题(除了用户自己开几百个窗口然后使劲怼),正常人很少会发生缓存不一致,就算真的发生了,也可以给缓存数据加上过期时间,每隔一段时间触发读的主动更新即可,毕竟这种场景还是可以容忍数据不一致情况的(比如用户更新了数据,当去看的时候发现没有更新,过段时间又更新了)。
2、就算并发很高,如果业务上能容忍短时间的缓存数据不一致(如商品名称,商品分类菜单等),缓存加上过期时间依然可以解决大部分业务对于缓存的要求。
3、如果不能容忍缓存数据不一致,可以通过加读写锁保证并发读写或写写的时候按顺序排好队(读写或写写加锁,读读不加锁),读读的时候相当于无锁。也就不会发现数据库与缓存数据不一致(redisson有读写锁的api直接使用,当然jdk也有,只不过redisson用的lua脚本,也可以通过zk来实现读写锁)
4、也可以用阿里开源的canal通过监听数据库的binlog日志及时的去修改缓存,但是引入了新的中间件,增加了系统的复杂度
2、就算并发很高,如果业务上能容忍短时间的缓存数据不一致(如商品名称,商品分类菜单等),缓存加上过期时间依然可以解决大部分业务对于缓存的要求。
3、如果不能容忍缓存数据不一致,可以通过加读写锁保证并发读写或写写的时候按顺序排好队(读写或写写加锁,读读不加锁),读读的时候相当于无锁。也就不会发现数据库与缓存数据不一致(redisson有读写锁的api直接使用,当然jdk也有,只不过redisson用的lua脚本,也可以通过zk来实现读写锁)
4、也可以用阿里开源的canal通过监听数据库的binlog日志及时的去修改缓存,但是引入了新的中间件,增加了系统的复杂度
以上我们针对的都是读多写少的情况加入缓存提高性能,如果写多读多的情况又不能容忍缓存数据不一致,那就没必要加缓存了,可以直接操作数据库。放入缓存的数据应该是对实时性、一致性要求不是很高的数据。切记不要为了用缓存,同时又要保证绝对的一致性做大量的过度设计和控制,增加系统复杂性!
开发规范与性能优化
键值设计
key名设计
1)【建议】: 可读性和可管理性
以业务名(或数据库名)为前缀(防止key冲突),用冒号分隔,比如业务名:表名:id
trade:order:1
(2)【建议】:简洁性
保证语义的前提下,控制key的长度,当key较多时,内存占用也不容忽视,例如:
user:{uid}:friends:messages:{mid} 简化为 u:{uid}:fr:m:{mid}
user:{uid}:friends:messages:{mid} 简化为 u:{uid}:fr:m:{mid}
(3)【强制】:不要包含特殊字符
反例:包含空格、换行、单双引号以及其他转义字符
value设计
(1)【强制】:拒绝bigkey(占用网卡流量、慢查询占用时间)
在Redis中,一个字符串最大512MB,一个二级数据结构(例如hash、list、set、zset)可以存储大约40亿个(2^32-1)个元素,但实际中如果下面两种情况,我就会认为它是bigkey(是否big没有一个标准,凭感觉来就行了,哈哈):
- 字符串类型:它的big体现在单个value值很大,一般认为超过10KB就是bigkey。
- 非字符串类型:哈希、列表、集合、有序集合,它们的big体现在元素个数太多。
一般来说,string类型控制在10KB以内,hash、list、set、zset元素个数不要超过5000。
非字符串的bigkey,不要使用del删除,使用hscan、sscan、zscan方式渐进式删除,同时要注意防止bigkey过期时间自动删除问题(例如一个200万的zset设置1小时过期,会触发del操作,造成阻塞)(一个bigkey删除跟查询所有都是很耗费时间以及占用网卡的,不过这里说的删除是指没使用异步删除的配置)
防止bigkey
bigkey的危害
- 导致redis阻塞:查询跟同步删除都会造成阻塞
- 网络拥塞
- 同步过期删除阻塞redis
bigkey的产生
一般来说,bigkey的产生都是由于程序设计不当,或者对于数据规模预料不清楚造成的,来看几个例子:
(1) 社交类:粉丝列表,如果某些明星或者大v不精心设计下(就是说,本来粉丝少是没事,但是粉丝多了就bigkey了),必是bigkey。
(2) 统计类:例如按天存储某项功能或者网站的用户集合,除非没几个人用,否则必是bigkey。
(3) 缓存类:将数据从数据库load出来序列化放到Redis里,这个方式非常常用,但有两个地方需要注意:
第一,是不是有必要把所有字段都缓存;
第二,有没有没用的相关关联的数据,有的程序员为了图方便把相关数据都存一个key下,产生bigkey
如何优化bigkey
拆
big list: 拆成多个 list1、list2、...listN
big hash:可以将数据分段存储,比如一个大的key,假设存了1百万的用户数据,可以拆分成200个key,每个key下面存放5000个用户数据
如果bigkey不可避免,也要思考一下要不要每次把所有元素都取出来(例如有时候仅仅需要hmget,而不是hgetall),删除也是一样,尽量使用优雅的方式来处理
big hash:可以将数据分段存储,比如一个大的key,假设存了1百万的用户数据,可以拆分成200个key,每个key下面存放5000个用户数据
如果bigkey不可避免,也要思考一下要不要每次把所有元素都取出来(例如有时候仅仅需要hmget,而不是hgetall),删除也是一样,尽量使用优雅的方式来处理
【推荐】:选择适合的数据类型。
例如:实体类型(要合理控制和使用数据结构,但也要注意节省内存和性能之间的平衡)
set user:1:age 19
set user:1:favor football
- 反例:
set user:1:age 19
set user:1:favor football
- 正例:
【推荐】:控制key的生命周期,redis不是垃圾桶。
建议使用expire设置过期时间(条件允许可以打散过期时间,防止集中过期),一些没用的数据不用一直缓存在redis中。
命令使用
1.【推荐】 O(N)命令关注N的数量
例如hgetall、lrange、smembers、zrange、sinter等并非不能使用,但是需要明确N的值。有遍历的需求可以使用hscan、sscan、zscan代替
2.【推荐】:禁用命令
禁止线上使用keys、flushall(清空整个 Redis 服务器的数据)、flushdb(清空当前数据库中的所有 key)等,通过redis的rename机制禁掉命令,或者使用scan的方式渐进式处理
禁止线上使用keys、flushall(清空整个 Redis 服务器的数据)、flushdb(清空当前数据库中的所有 key)等,通过redis的rename机制禁掉命令,或者使用scan的方式渐进式处理
3.【推荐】合理使用select
redis的多数据库较弱,使用数字进行区分,很多客户端支持较差,同时多业务用多数据库实际还是单线程处理,会有干扰
redis的多数据库较弱,使用数字进行区分,很多客户端支持较差,同时多业务用多数据库实际还是单线程处理,会有干扰
4.【推荐】使用批量操作提高效率
原生命令:例如mget、mset。
非原生命令:可以使用pipeline提高效率。
但要注意控制一次批量操作的元素个数(例如500以内,实际也和元素字节数有关)
原生命令:例如mget、mset。
非原生命令:可以使用pipeline提高效率。
但要注意控制一次批量操作的元素个数(例如500以内,实际也和元素字节数有关)
5.【建议】Redis事务功能较弱,不建议过多使用,可以用lua替代
客户端使用
1.【推荐】使用多台redis实例
避免多个应用使用一个Redis实例
正例:不相干的业务拆分,公共数据做服务化
避免多个应用使用一个Redis实例
正例:不相干的业务拆分,公共数据做服务化
2.【推荐】使用带有连接池的数据库
使用带有连接池的数据库,可以有效控制连接,同时提高效率
使用带有连接池的数据库,可以有效控制连接,同时提高效率
3.【建议】客户端添加熔断降级
高并发下建议客户端添加熔断功能(例如sentinel、hystrix)
高并发下建议客户端添加熔断功能(例如sentinel、hystrix)
4.【推荐】设置合理的密码
设置合理的密码,如有必要可以使用SSL加密访问
设置合理的密码,如有必要可以使用SSL加密访问
5. 注意结合过期key淘汰策略考虑
redis连接池
连接池参数含义
- maxTotal 资源池中最大连接数 8 设置建议见下面
- maxIdle 资源池允许最大空闲的连接数 8 设置建议见下面
- minIdle 资源池确保最少空闲的连接数 0 设置建议见下面
- blockWhenExhausted 当资源池用尽后,调用者是否要等待。只有当为true时,下面的maxWaitMillis才会生效 true 建议使用默认值
- maxWaitMillis 当资源池连接用尽后,调用者的最大等待时间(单位为毫秒) -1:表示永不超时 不建议使用默认值
- testOnBorrow 向资源池借用连接时是否做连接有效性检测(ping),无效连接会被移除 false 业务量很大时候建议设置为false(多一次ping的开销)。
- testOnReturn 向资源池归还连接时是否做连接有效性检测(ping),无效连接会被移除 false 业务量很大时候建议设置为false(多一次ping的开销)。
- jmxEnabled 是否开启jmx监控,可用于监控 true 建议开启,但应用本身也要开启
优化建议
1)maxTotal:最大连接数,早期的版本叫maxActive。实际上这个是一个很难回答的问题,考虑的因素比较多:
- 业务希望Redis并发量
- 客户端执行命令时间
- Redis资源:例如 nodes(例如应用个数) * maxTotal 是不能超过redis的最大连接数maxTotal。
- 资源开销:例如虽然希望控制空闲连接(连接池此刻可马上使用的连接),但是不希望因为连接池的频繁释放创建连接造成不必靠开销。
2)maxIdle和minIdle
maxIdle实际上才是业务需要的最大连接数,maxTotal是为了给出余量,所以maxIdle不要设置过小,否则会有new Jedis(新连接)开销。
连接池的最佳性能是maxTotal = maxIdle,这样就避免连接池伸缩带来的性能干扰。但是如果并发量不大或者maxTotal设置过高,会导致不必要的连接资源浪费。一般推荐maxIdle可以设置为按上面的业务期望QPS计算出来的理论连接数,maxTotal可以再放大一倍。
minIdle(最小空闲连接数),与其说是最小空闲连接数,不如说是"至少需要保持的空闲连接数",在使用连接的过程中,如果连接数超过了minIdle,那么继续建立连接,如果超过了maxIdle,当超过的连接执行完业务后会慢慢被移出连接池释放掉
maxIdle实际上才是业务需要的最大连接数,maxTotal是为了给出余量,所以maxIdle不要设置过小,否则会有new Jedis(新连接)开销。
连接池的最佳性能是maxTotal = maxIdle,这样就避免连接池伸缩带来的性能干扰。但是如果并发量不大或者maxTotal设置过高,会导致不必要的连接资源浪费。一般推荐maxIdle可以设置为按上面的业务期望QPS计算出来的理论连接数,maxTotal可以再放大一倍。
minIdle(最小空闲连接数),与其说是最小空闲连接数,不如说是"至少需要保持的空闲连接数",在使用连接的过程中,如果连接数超过了minIdle,那么继续建立连接,如果超过了maxIdle,当超过的连接执行完业务后会慢慢被移出连接池释放掉
redis过期key清除策略
三种清除策略
被动删除:当读/写一个已经过期的key时,会触发惰性删除策略,直接删除掉这个过期key
主动删除:由于惰性删除策略无法保证冷数据被及时删掉,所以Redis会定期主动淘汰一批已过期的key
当前已用内存超过maxmemory限定时,触发主动清理策略
主动清除策略的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只响应读操作
LRU 算法(Least Recently Used,最近最少使用):淘汰很久没被访问过的数据,以最近一次访问时间作为参考。
LFU 算法(Least Frequently Used,最不经常使用):淘汰最近一段时间被访问次数最少的数据,以次数作为参考
LFU 算法(Least Frequently Used,最不经常使用):淘汰最近一段时间被访问次数最少的数据,以次数作为参考
注意:当Redis运行在主从模式时,只有主结点才会执行过期删除策略,然后把删除操作”del key”同步到从结点删除数据
问题整理
简单介绍一下 Redis 呗!
简单来说 Redis 就是一个使用 C 语言开发的数据库,不过与传统数据库不同的是 Redis 的数据是存在内存中的 ,也就是它是内存数据库,所以读写速度非常快,因此 Redis 被广泛应用于缓存方向。
另外,Redis 除了做缓存之外,Redis 也经常用来做分布式锁,甚至是消息队列。
Redis 提供了多种数据类型来支持不同的业务场景。Redis 还支持事务 、持久化、Lua 脚本、多种集群方案
缓存数据的处理流程是怎样的?
- 如果用户请求的数据在缓存中就直接返回。
- 缓存中不存在的话就看数据库中是否存在。
- 数据库中存在的话就更新缓存中的数据。
- 数据库中不存在的话就返回空数据
为什么要用 Redis缓存?
用缓存主要是为了提升用户体验以及应对更多的用户,主要从“高性能”和“高并发”这两点来看待这个问题
高性能:mysql查询需要从磁盘中获取数据,而redis是直接从内存中拿数据,而且redis的<key,value>形式能达到O(1),速度比mysql快太多
高并发:既然查询速度快得多,自然能支撑的并发也会高的多,redis官方说的能支持每秒10w并发
高性能:mysql查询需要从磁盘中获取数据,而redis是直接从内存中拿数据,而且redis的<key,value>形式能达到O(1),速度比mysql快太多
高并发:既然查询速度快得多,自然能支撑的并发也会高的多,redis官方说的能支持每秒10w并发
Redis 常见数据结构以及使用场景分析
Redis 单线程模型详解
Redis的IO多路复用:redis利用epoll来实现IO多路复用,将连接信息和事件放到队列中,依次放到文件事件分派器(根据事件类型分派到不同处理器中处理),事件分派器将事件分发给事件处理器。这就是Redis 单线程如何处理那么多的并发客户端连接的原因。这个结合Netty的线程模型看容易理解
多个 socket(客户端连接)
IO 多路复用程序(支持多个客户端连接的关键)
文件事件分派器(将 socket 关联到相应的事件处理器)
事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)
多路复用跟处理命令是两个线程,多路保证按顺序丢到队列,事件处理单线程处理
Redis 没有使用多线程?为什么不使用多线程?
为什么不使用多线程:
- 单线程编程容易并且更容易维护;
- Redis 的性能瓶颈不再 CPU ,主要在内存和网络;
- 多线程就会存在死锁、线程上下文切换等问题,甚至会影响性能。
Redis是单线程吗?
Redis 的单线程主要是指 Redis 的网络 IO 和键值对读写是由一个线程来完成的,这也是 Redis 对外提供键值存储服务的主要流程。但 Redis 的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的
Redis 单线程为什么还能这么快?
因为它所有的数据都在内存中,所有的运算都是内存级别的运算,而且单线程避免了多线程的切换性能损耗问题。正因为 Redis 是单线程,所以要小心使用 Redis 指令,对于那些耗时的指令(比如keys),一定要谨慎使用,一不小心就可能会导致 Redis 卡顿。 当然还有一个原因就是因为redis的多路复用IO模型。redis官方说明是支持每秒10w级别的命令处理
Redis的IO多路复用:redis利用epoll来实现IO多路复用,将连接信息和事件放到队列中,依次放到文件事件分派器(根据事件类型分派到不同处理器中处理),事件分派器将事件分发给事件处理器。这就是Redis 单线程如何处理那么多的并发客户端连接的原因。这个结合Netty的线程模型看容易理解
Redis6.0 之后为何引入了多线程?
Redis6.0 引入多线程主要是为了提高网络 IO 读写性能,因为这个算是 Redis 中的一个性能瓶颈(Redis 的瓶颈主要受限于内存和网络)
redis 6.0 之前线程执行模式
redis 6.0 以前线程执行模式,如下操作在一个线程中执行完成:read(读取命令)-->parse解析命令-->command execute(执行命令)-->write(响应客户端)
redis 6.0 线程执行模式
可以通过如下参数配置多线程模型(默认都是不开启的):如:
io-threads 4 // 这里说 有三个IO 线程,还有一个线程是main线程,main线程负责IO读写和命令执行操作
默认情况下,如上配置,有三个IO线程, 这三个IO线程只会执行 IO中的write 操作,也就是说,read 和 命令执行 都由main线程执行。最后写回客户端才是多线程执行
io-threads 4 // 这里说 有三个IO 线程,还有一个线程是main线程,main线程负责IO读写和命令执行操作
默认情况下,如上配置,有三个IO线程, 这三个IO线程只会执行 IO中的write 操作,也就是说,read 和 命令执行 都由main线程执行。最后写回客户端才是多线程执行
如想多线程执行命令步骤需要开启如下配置,同样执行命令的操作还是只有main线程完成【多线程读命令、解析命令、写回结果,单线程执行命令】
开启如下参数将支持IO线程执行 读写任务:
io-threads-do-reads yes // 将支持IO线程执行 读写任务。
开启如下参数将支持IO线程执行 读写任务:
io-threads-do-reads yes // 将支持IO线程执行 读写任务。
Redis 给缓存数据设置过期时间有啥用?
因为内存是有限的,如果缓存中的所有数据都是一直保存的话,分分钟直接Out of memory
很多时候,我们的业务场景就是需要某个数据只在某一时间段内存在,比如我们的短信验证码可能只在1分钟内有效,用户登录的 token 可能只在 1 天内有效
另外还能起到数据准确性,怎么说呢,比如可能因为并发原因缓存到了错误的老数据,当在过期清除后触发重新缓存也就修正了数据
Redis是如何判断数据是否过期的呢?
Redis 通过一个叫做过期字典(可以看作是hash表)来保存数据过期的时间。过期字典的键指向Redis数据库中的某个key(键),过期字典的值是一个long long类型的整数,这个整数保存了key所指向的数据库键的过期时间(毫秒精度的UNIX时间戳)
过期的数据的删除策略了解么?
常用的过期数据的删除策略就两个(重要!自己造缓存轮子的时候需要格外考虑的东西):
但是,仅仅通过给 key 设置过期时间还是有问题的。因为还是可能存在定期删除和惰性删除漏掉了很多过期 key 的情况。这样就导致大量过期 key 堆积在内存里,然后就Out of memory了。
怎么解决这个问题呢?答案就是: Redis 内存淘汰机制
- 惰性删除 :只会在取出key的时候才对数据进行过期检查。这样对CPU最友好,但是可能会造成太多过期 key 没有被删除。
- 定期删除 : 每隔一段时间抽取一批 key 执行删除过期key操作。并且,Redis 底层会通过限制删除操作执行的时长和频率来减少删除操作对CPU时间的影响。
但是,仅仅通过给 key 设置过期时间还是有问题的。因为还是可能存在定期删除和惰性删除漏掉了很多过期 key 的情况。这样就导致大量过期 key 堆积在内存里,然后就Out of memory了。
怎么解决这个问题呢?答案就是: Redis 内存淘汰机制
Redis 内存淘汰机制了解么?
主动清除策略的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只响应读操作
LRU 算法(Least Recently Used,最近最少使用):淘汰很久没被访问过的数据,以最近一次访问时间作为参考。
LFU 算法(Least Frequently Used,最不经常使用):淘汰最近一段时间被访问次数最少的数据,以次数作为参考
LFU 算法(Least Frequently Used,最不经常使用):淘汰最近一段时间被访问次数最少的数据,以次数作为参考
Redis 持久化机制(怎么保证 Redis 挂掉之后再重启数据可以进行恢复)
快照(snapshotting)持久化(RDB)
Redis 可以通过创建快照来获得存储在内存里面的数据在某个时间点上的副本。Redis 创建快照之后,可以对快照进行备份,可以将快照复制到其他服务器从而创建具有相同数据的服务器副本(Redis 主从结构,主要用来提高 Redis 性能),还可以将快照留在原地以便重启服务器的时候使用
快照持久化是 Redis 默认采用的持久化方式,在 Redis.conf 配置文件中默认有此下配置:
- save 900 1 #在900秒(15分钟)之后,如果至少有1个key发生变化,Redis就会自动触发BGSAVE命令创建快照。
- save 300 10 #在300秒(5分钟)之后,如果至少有10个key发生变化,Redis就会自动触发BGSAVE命令创建快照。
- save 60 10000 #在60秒(1分钟)之后,如果至少有10000个key发生变化,Redis就会自动触发BGSAVE命令创建快照。
AOF(append-only file)持久化
将修改的每一条指令记录进文件appendonly.aof中(先写入os cache,每隔一段时间fsync到磁盘)
打开方式:默认是没有打开的,你可以通过修改配置文件来打开 AOF 功能:
appendonly yes
appendonly yes
可以配置 Redis 多久才将数据 fsync 到磁盘一次。有三个选项:
- appendfsync always:每次有新命令追加到 AOF 文件时就执行一次 fsync ,非常慢,也非常安全。最多丢失一条命令
- appendfsync everysec:每秒 fsync 一次,足够快,并且在故障时只会丢失 1 秒钟的数据。当打开aof后默认为这个策略
- appendfsync no:从不 fsync ,将数据交给操作系统来处理。更快,也更不安全的选择
AOF 重写
AOF文件里可能有太多没用指令,所以AOF会定期根据内存的最新数据生成aof文件
如下两个配置可以控制AOF自动重写频率
当然AOF还可以手动重写,进入redis客户端执行命令bgrewriteaof重写AOF
- # auto-aof-rewrite-min-size 64mb //aof文件至少要达到64M才会自动重写,文件太小恢复速度本来就很快,重写的意义不大
- # auto-aof-rewrite-percentage 100 //aof文件自上一次重写后文件大小增长了100%则再次触发重写
当然AOF还可以手动重写,进入redis客户端执行命令bgrewriteaof重写AOF
在执行 BGREWRITEAOF 命令时,Redis 服务器会维护一个 AOF 重写缓冲区,该缓冲区会在子进程创建新 AOF 文件期间,记录服务器执行的所有写命令。当子进程完成创建新 AOF 文件的工作之后,服务器会将重写缓冲区中的所有内容追加到新 AOF 文件的末尾,使得新旧两个 AOF 文件所保存的数据库状态一致。最后,服务器用新的 AOF 文件替换旧的 AOF 文件,以此来完成 AOF 文件重写操作
Redis 事务
Redis 可以通过 MULTI,EXEC,DISCARD 和 WATCH 等命令来实现事务(transaction)功能
使用 MULTI命令后可以输入多个命令。Redis不会立即执行这些命令,而是将它们放到队列,当调用了EXEC命令将执行所有命令。
Redis 是不支持 roll back 的,因而不满足原子性的(而且不满足持久性),你可以将Redis中的事务就理解为 :Redis事务提供了一种将多个命令请求打包的功能。然后,再按顺序执行打包的所有命令,并且不会被中途打断,如果要控制事务建议使用lua脚本来写
缓存穿透
什么是缓存穿透?
解决方案
缓存雪崩
什么是缓存雪崩?
解决方案
如何保证缓存和数据库数据的一致性?
就是上面的缓存与数据库数据不一致问题,说白了都是双写并发问题
nvm非易失内存
新型非易失存储(Non-Volatile Memory,NVM)器件发展得非常快。NVM 器件具有容量大、性能快、能持久化保存数据的特性,这些刚好就是 Redis 追求的目标。同时,NVM 器件像 DRAM 一样,可以让软件以字节粒度进行寻址访问,所以,在实际应用中,NVM 可以作为内存来使用,我们称为 NVM 内存
Redis 是基于 DRAM 内存的键值数据库,而跟传统的 DRAM 内存相比,NVM 有三个显著的特点。
- 持久化保存数据:数据保存在 NVM 内存上后,即使发生了宕机或是掉电,数据仍然存在 NVM 内存上。但如果数据是保存在 DRAM 上,那么,掉电后数据就会丢失。
- 访问速度接近 DRAM 的速度:读延迟大约是 200~300ns,而写延迟大约是 100ns。在读写带宽方面,单根 NVM 内存条的写带宽大约是 1~2GB/s,而读带宽约是 5~6GB/s。当软件系统把数据保存在 NVM 内存上时,系统仍然可以快速地存取数据。
- 内存容量大:NVM 器件的密度大,单个 NVM 的存储单元可以保存更多数据。例如,单根 NVM 内存条就能达到 128GB 的容量,最大可以达到 512GB,而单根 DRAM 内存条通常是 16GB 或 32GB。所以,我们可以很轻松地用 NVM 内存构建 TB 级别的内存
Zookeeper
Zookeeper特性与节点数据类型详解
Zookeeper 核心概念:Zookeeper 是一个用于存储少量数据的基于内存的数据库(java语言写的),它是一个分布式协调框架,是Apache Hadoop 的一个子项目,它主要是用来解决分布式应用中经常遇到的一些数据管理问题,如:统一命名服务、状态同步服务、集群管理、分布式应用配置项的管理等。跟redis一样全部数据也都是存于内存中,同样也会有数据持久化机制(跟redis也相似),主要有如下两个核心的概念:文件系统数据结构+监听通知机制
文件系统数据结构与监听/通知机制
文件系统数据结构
每个子目录项都被称作为 znode(目录节点),和文件系统类似,我们能够自由的增加、删除znode,在一个znode下增加、删除子znode
znode目录节点类型
PERSISTENT-持久化目录节点:客户端与zookeeper断开连接后,该节点依旧存在,只要不手动删除该节点,他将永远存在
PERSISTENT_SEQUENTIAL-持久化顺序编号目录节点:客户端与zookeeper断开连接后,该节点依旧存在,只是Zookeeper给该节点名称进行顺序编号
EPHEMERAL-临时目录节点:客户端与zookeeper断开连接后,该节点被删除
EPHEMERAL_SEQUENTIAL-临时顺序编号目录节点:客户端与zookeeper断开连接后,该节点被删除,只是Zookeeper给该节点名称进行顺序编号
Container 节点:3.5.1 版本新增,如果Container节点下面没有子节点,则Container节点在未来会被Zookeeper自动清除,定时任务默认60s 检查一次
TTL 节点:过了TTL指定的时间时,会被服务器删除(3.5.1 版本新增,默认禁用,只能通过系统配置 zookeeper.extendedTypesEnabled=true 开启,不稳定)
监听通知机制
客户端注册监听它关心的任意节点,或者目录节点及递归子目录节点
- 1. 如果注册的是对某个节点的监听,则当这个节点被删除,或者被修改时,对应的客户端将被通知
- 2. 如果注册的是对某个目录的监听,则当这个目录有子节点被创建,或者有子节点被删除,对应的客户端将被通知
- 3. 如果注册的是对某个目录的递归子节点进行监听,则当这个目录下面的任意子节点有目录结构的变化(有子节点被创建,或被删除)或者根节点有数据变化时,对应的客户端将被通知。
注意:所有的通知都是一次性的,及无论是对节点还是对目录进行的监听,一旦触发,对应的监听即被移除。递归子节点,监听是对所有子节点的,所以,每个子节点下面的事件同样只会被触发一次。
Zookeeper 经典的应用场景
1. 分布式配置中心
2. 分布式注册中心
3. 分布式锁
4. 分布式队列
5. 集群选举
6. 分布式屏障
7. 发布/订阅
ZooKeeper 内存数据和持久化
内存中的数据:
public class DataTree {
private final ConcurrentHashMap<String, DataNode> nodes = new ConcurrentHashMap<String, DataNode>();
private final WatchManager dataWatches = new WatchManager();
private final WatchManager childWatches = new WatchManager();
public class DataTree {
private final ConcurrentHashMap<String, DataNode> nodes = new ConcurrentHashMap<String, DataNode>();
private final WatchManager dataWatches = new WatchManager();
private final WatchManager childWatches = new WatchManager();
DataNode 是Zookeeper存储节点数据的最小单位
public class DataNode implements Record {
byte data[];
Long acl;
public StatPersisted stat;
private Set<String> children = null;
public class DataNode implements Record {
byte data[];
Long acl;
public StatPersisted stat;
private Set<String> children = null;
基本操作命令
ls列出子节点命令
ls [-s] [-w] [-R] path
-s 状态信息
-R 递归查看所有子节点
-w 添加一个 watch(监视器),如果该节点发生变化,watch 可以使客户端得到通知。watch 只能被触发一次。如果要一直获得 znode 的创建和删除的通知,
那么就需要不断的在znode上开启观察模式。如果在该 path 下节点发生变化,会产生 NodeChildrenChanged 事件,删除节点,会产生 NodeDeleted 事件。
create创建节点命令
create [-s] [-e] [-c] [-t ttl] path [data] [acl]
中括号为可选项,没有则默认创建持久化节点
-s: 顺序节点
-e: 临时节点
-c: 容器节点
-t: 可以给节点添加过期时间,默认禁用,需要通过系统参数启用
中括号为可选项,没有则默认创建持久化节点
-s: 顺序节点
-e: 临时节点
-c: 容器节点
-t: 可以给节点添加过期时间,默认禁用,需要通过系统参数启用
临时节点不能创建子节点,Zookeeper中临时节点生命周期是和SESSION绑定的
create -c 创建容器节点,如果Container节点下面以前有现在没有子节点(以前没有现在也没有是不会被清除的,表现跟持久化节点一样),则Container节点在未来会被Zookeeper自动清除,定时任务默认60s 检查一次
delete和deleteall命令
删除节点(不能存在子节点)
delete [-v version] path
delete [-v version] path
删除所有节点(自己也会删除) 包括子节点
deleteall path
deleteall path
set为节点设置值
set [-s] [-v version] path data
-s 除了设置节点值,还会多输出节点状态信息
-v 修改的时候可以带上版本号,如果版本号跟当前数据版本不同则修改失败
-s 除了设置节点值,还会多输出节点状态信息
-v 修改的时候可以带上版本号,如果版本号跟当前数据版本不同则修改失败
通过set带上版本号实现乐观锁
①:客户端A首先获取版本信息, get -s /node-test
②:/node-test 当前的数据版本是 0 , 启动另外一台客户端B,修改node-test的值,版本变成1
③:这时客户端A 用 set 命令修改数据的时候把之前获取到的版本号(0)带上 ,因为版本号不同所以修改失败
get获取节点值
get [-s] [-w] path
-s 除了输出节点值,还会多输出节点状态信息
-w 添加一个 watch(监视器),如果节点内容发生改变,会产生 NodeDataChanged 事件;如果删除节点,会产生 NodeDeleted 事件。
-s 除了输出节点值,还会多输出节点状态信息
-w 添加一个 watch(监视器),如果节点内容发生改变,会产生 NodeDataChanged 事件;如果删除节点,会产生 NodeDeleted 事件。
stat 查看节点状态信息
语法:与 get 的区别是,不会列出 znode 的值。
stat [-w] path
-w 添加一个 watch(监视器),如果节点内容发生改变,会产生 NodeDataChanged 事件;如果删除节点,会产生 NodeDeleted 事件
stat [-w] path
-w 添加一个 watch(监视器),如果节点内容发生改变,会产生 NodeDataChanged 事件;如果删除节点,会产生 NodeDeleted 事件
Stat输出信息:
- cZxid:创建znode的事务ID(Zxid的值)。
- mZxid:最后修改znode的事务ID。
- pZxid:最后添加或删除子节点的事务ID(子节点列表发生变化才会发生改变)。
- ctime:znode创建时间。
- mtime:znode最近修改时间。
- dataVersion:znode的当前数据版本。
- cversion:znode的子节点结果集版本(一个节点的子节点增加、删除都会影响这个版本)。
- aclVersion:表示对此znode的acl版本。
- ephemeralOwner:znode是临时znode时,表示znode所有者的 session ID。 如果znode不是临时znode,则该字段设置为零。
- dataLength:znode数据字段的长度。
- numChildren:znode的子znode的数量
事件监听机制
针对节点的监听
get -w /path // 注册监听的同时获取数据
stat -w /path // 对节点进行监听,且获取元数据信息
①:客户端A注册监听,get -w /test
②:客户端B修改数据,set /test data
③:客户端立马触发NodeDataChanged监听事件
④:如果再次修改并不会触发监听了,因为监听已经被删除了,这是一次性的
②:客户端B修改数据,set /test data
③:客户端立马触发NodeDataChanged监听事件
④:如果再次修改并不会触发监听了,因为监听已经被删除了,这是一次性的
针对目录的监听
ls -w /path
//针对递归子目录的监听
ls -R -w /path : -R 区分大小写,一定用大写
//针对递归子目录的监听
ls -R -w /path : -R 区分大小写,一定用大写
如下对/test (子节点node01、node02)节点进行递归监听,但是每个目录下的目录监听也是一次性的,如第一次在/test/node01 目录下创建节点时,触发监听事件,第二次则没有,同样,因为是递归的目录监听,所以在/test/node02下进行节点创建时,触发事件,但是再次创建时,没有触发事件。简单说就是循环给每个都设置了监听事件(一次性),当然/test字节本身也添加了监听
Zookeeper事件类型
None 连接建立事件
NodeCreated 节点创建
NodeDeleted 节点删除
NodeDataChanged 节点数据变化
NodeChildrenChanged 子节点列表变化
DataWatchRemoved 节点监听被移除
ChildWatchRemoved 子节点监听被移除
Zookeeper 的 ACL 权限控制( Access Control List )
ACL 权限控制
Zookeeper 的ACL 权限控制,可以控制节点的读写操作,保证数据的安全性,Zookeeper ACL 权限设置分为 3 部分组成,分别是:权限模式(Scheme)、授权对象(ID)、权限信息(Permission)。最终组成一条例如“scheme:id:permission”格式的 ACL 请求信息
Scheme(权限模式):用来设置 ZooKeeper 服务器进行权限验证的方式。ZooKeeper 的权限验证方式大体分为两种类型:
world:开放模式(默认值),其实这种授权模式对应于系统中的所有用户,本质上起不到任何作用。设置了 world 权限模式系统中的所有用户操作都可以不进行权限验证。
world:开放模式(默认值),其实这种授权模式对应于系统中的所有用户,本质上起不到任何作用。设置了 world 权限模式系统中的所有用户操作都可以不进行权限验证。
- ip:一种是范围验证。所谓的范围验证就是说 ZooKeeper 可以针对一个 IP 或者一段 IP 地址授予某种权限。比如我们可以让一个 IP 地址为“ip:192.168.0.110”的机器对服务器上的某个数据节点具有写入的权限。或者也可以通过“ip:192.168.0.1/24”给一段 IP 地址的机器赋权。
- digest :另一种权限模式就是口令验证,也可以理解为用户名密码的方式。在 ZooKeeper 中这种验证方式是 Digest 认证,而 Digest 这种认证方式首先在客户端传送“username:password”这种形式的权限表示符后,ZooKeeper 服务端会对密码 部分使用 SHA-1 和 BASE64 算法进行加密,以保证安全性。还可以auth,用户密码认证模式,只有在会话中添加了认证才可以防问。digest与auth类似,区别在于auth用明文密码,而digest 用sha-1+base64加密后的密码。在实际使用中digest 更常见。
- 还有一种Super权限模式, Super可以认为是一种特殊的 Digest 认证。具有 Super 权限的客户端可以对 ZooKeeper 上的任意数据节点进行任意操作
授权对象(ID):授权对象就是说我们要把权限赋予谁,而对应于 4 种不同的权限模式来说,如果我们选择采用 IP 方式,使用的授权对象可以是一个 IP 地址或 IP 地址段;而如果使用 Digest 或 Super 方式,则对应于一个用户名。如果是 World 模式,是授权系统中所有的用户
权限信息(Permission):权限就是指我们可以在数据节点上执行的操作种类,如下所示:在 ZooKeeper 中已经定义好的权限有 5 种:
- 数据节点(c: create)创建权限,授予权限的对象可以在数据节点下创建子节点;
- 数据节点(w: wirte)更新权限,授予权限的对象可以更新该数据节点;
- 数据节点(r: read)读取权限,授予权限的对象可以读取该节点的内容以及子节点的列表信息;
- 数据节点(d: delete)删除权限,授予权限的对象可以删除该数据节点的子节点;
- 数据节点(a: admin)管理者权限,授予权限的对象可以对该数据节点体进行 ACL 权限设置
acl 相关命令
命令 使用方式 描述
getAcl getAcl <path> 获取某个节点的acl权限信息
setAcl setAcl <path> <acl> 设置某个节点的acl权限信息
addauth addauth <scheme> <auth> 输入认证授权信息,相当于注册用户信息,注册时输入明文密码,zk将以密文的形式存储
getAcl getAcl <path> 获取某个节点的acl权限信息
setAcl setAcl <path> <acl> 设置某个节点的acl权限信息
addauth addauth <scheme> <auth> 输入认证授权信息,相当于注册用户信息,注册时输入明文密码,zk将以密文的形式存储
可以通过系统参数zookeeper.skipACL=yes进行配置,默认是no,可以配置为true, 则配置过的ACL将不再进行权限检测
设置ACL有两种方式
节点创建的同时设置ACL
create [-s] [-e] [-c] path [data] [acl]
create [-s] [-e] [-c] path [data] [acl]
或者用setAcl 设置
setAcl /zk-node digest:libo:NEWhlz5DqG8dUwfzG9BaOrVjTVA=:cdrwa
setAcl /zk-node digest:libo:NEWhlz5DqG8dUwfzG9BaOrVjTVA=:cdrwa
事务日志
zookeeper事务日志
针对每一次客户端的事务操作,Zookeeper都会将他们记录到事务日志中,当然,Zookeeper也会将数据变更应用到内存数据库中。我们可以在zookeeper的主配置文件zoo.cfg 中配置内存中的数据持久化目录,也就是事务日志的存储路径 dataLogDir. 如果没有配置dataLogDir(非必填), 事务日志将存储到dataDir (必填项)目录
zookeeper提供了格式化工具可以进行数据查看事务日志数据
事务日志文件预分配:Zookeeper进行事务日志文件操作的时候会频繁进行磁盘IO操作,事务日志的不断追加写操作会触发底层磁盘IO为文件开辟新的磁盘块,即磁盘Seek。因此,为了提升磁盘IO的效率,Zookeeper在创建事务日志文件的时候就进行文件空间的预分配- 即在创建文件的时候,就向操作系统申请一块大一点的磁盘块。这个预分配的磁盘大小可以通过系统参数 zookeeper.preAllocSize 进行配置
事务日志文件名为: log.<当时最大事务ID>,应为日志文件时顺序写入的,所以这个最大事务ID也将是整个事务日志文件中,最小的事务ID,日志满了即进行下一次事务日志文件的创建
数据快照
数据快照用于记录Zookeeper服务器上某一时刻的全量数据,并将其写入到指定的磁盘文件中
可以通过配置snapCount配置每间隔事务请求个数,生成快照,数据存储在dataDir 指定的目录中, 为了避免集群中所有机器在同一时间进行快照,实际的快照生成时机为事务数达到 [snapCount/2 + 随机数(随机数范围为1 ~ snapCount/2 )] 个数时开始快照
快照事务日志文件名为: snapshot.<当时最大事务ID>,日志满了即进行下一次事务日志文件的创建
有了事务日志,为啥还要快照数据?
快照数据主要时为了快速恢复, 事务日志文件是每次事务请求都会进行追加的操作,而快照是达到某种设定条件下的内存全量数据(就好比Redis的aof跟RDB一样)。所以通常快照数据是反应当时内存数据的状态。事务日志是更全面的数据,所以恢复数据的时候,可以先恢复快照数据,再通过增量恢复事务日志中的数据即可。
Zookeeper典型使用场景实战
Zookeeper分布式锁实战
zk实现非公平锁
实现原理
原理:基于节点创建了就不能再次创建。当线程获取锁后,其他不能获取锁的监听节点,当节点被删除触发监听通知,然后再去重新尝试获取锁。当然需要临时节点,防止宕机后无法释放锁导致死锁
存在的问题-羊群效应
如上实现方式在并发问题比较严重的情况下,性能会下降的比较厉害,主要原因是,所有的连接都在对同一个节点进行监听,当服务器检测到删除事件时,要通知所有的连接,所有的连接同时收到事件,再次并发竞争,这就是羊群效应(又叫惊群效应,当有一头羊奔跑起来【监听通知信号】,一群羊会【等待的线程】一拥而上)。这种加锁方式是非公平锁(抢到锁完全靠实力跟运气,并不能先进先来)的具体实现:如何避免呢,我们看下面这种方式
zk实现公平锁
实现原理
如上借助于临时顺序节点,可以避免同时多个节点的并发竞争锁,缓解了服务端压力。这种实现方式所有加锁请求都进行排队加锁,是公平锁的具体实现。
个别情况说明与优化
某一台服务宕机,没能主动释放锁:当一台服务挂了后,就算没有主动释放锁也没事,zk会在心跳检测不到一定时间后(默认30s)主动删除临时节点,从而触发下一个节点事件通知,被触发的节点就会先去判断自己是不是lock节点下最小的节点,发现不是,对前面的节点进行监听,也就恢复了监听链。就是在这段时间内其他线程不能获得锁,只能等待,所以这个时间需要设置恰当。redis实现分布式锁也一样存在这种问题,没办法避免
幽灵节点:当创建节点的时候,zk创建成功,客户端因为没有收到通知导致创建多个节点(重试机制),这个没有客户端与之关联的节点称为幽灵节点。会造成什么问题呢?这个幽灵节点如果变成了最小的节点(当前面比它小的节点都删除后),那么其他线程就会一直等待这个节点删除,但是这个节点又不会删除(服务端还是有保存这个节点的客户端session,客户端不宕机就永远不会被zk删掉),也就进入了无线等待的死锁状态。怎么避免产生幽灵节点?这个问题根本原因就是重复创建了,那我们不让它重复创建不就行了---绑定一个本线程的唯一标识,每次创建的时候都先判断一下,有就不创建,没有才创建,curator里面是通过绑定一个线程变成UUID来限定的,UUID作为节点前缀(zk创建临时节点的时候可以指定不同的前缀,只是后面数字不同而已)
幽灵节点:当创建节点的时候,zk创建成功,客户端因为没有收到通知导致创建多个节点(重试机制),这个没有客户端与之关联的节点称为幽灵节点。会造成什么问题呢?这个幽灵节点如果变成了最小的节点(当前面比它小的节点都删除后),那么其他线程就会一直等待这个节点删除,但是这个节点又不会删除(服务端还是有保存这个节点的客户端session,客户端不宕机就永远不会被zk删掉),也就进入了无线等待的死锁状态。怎么避免产生幽灵节点?这个问题根本原因就是重复创建了,那我们不让它重复创建不就行了---绑定一个本线程的唯一标识,每次创建的时候都先判断一下,有就不创建,没有才创建,curator里面是通过绑定一个线程变成UUID来限定的,UUID作为节点前缀(zk创建临时节点的时候可以指定不同的前缀,只是后面数字不同而已)
临时节点不能创建子节点,也就不能在其下创建临时顺序节点。所以需要创建一个持久化节点(更好的是使用容器节点,当没有子节点后会在未来会自动删除),然后在其下面创建临时顺序节点
zk实现读写锁(读写锁又为共享锁)
读写锁以及读写不一致情况
前面这两种加锁方式有一个共同的特质,就是都是互斥锁,同一时间只能有一个请求占用,如果是大量的并发上来,性能是会急剧下降的,所有的请求都得加锁,那是不是真的所有的请求都需要加锁呢?答案是否定的,比如如果数据没有进行任何修改的话,是不需要加锁的,但是如果读数据的请求还没读完,这个时候来了一个写请求,怎么办呢?有人已经在读数据了,这个时候是不能写数据的,不然数据就不正确了。直到前面读锁全部释放掉以后,写请求才能执行,所以需要给这个读请求加一个标识(读锁),让写请求知道,这个时候是不能修改数据的。不然数据就不一致了。如果已经有人在写数据了,再来一个请求写数据,也是不允许的,这样也会导致数据的不一致,所以所有的写请求,都需要加一个写锁,是为了避免同时对共享数据进行写操作
Zookeeper 共享锁实现原理
同样通过临时顺序节点,不同的是,每次请求都标注好前缀为读请求还是写请求。如上图,write监听上一个read或者write节点,read监听上一个write节点
- 读读允许:当前面全是read节点的时候(也就是只有读没有写线程),读读可以,但是不能写(读写不可以)。
- 读写不允许:write监听上一个read节点,当上一个read节点删除后,触发监听,接着判断前面还有没有任何节点,没有说明前面没有请求了,也就可以写了。
- 写读不允许:所有的read监听上一个write节点,当write节点释放后,触发监听,判断上面还有没有write节点,没有则可以读了
- 写写不允许:write监听上一个write节点,当上一个write节点释放后,触发监听,接着再次判断前面还有没有任何的读写节点,没有则可以获取锁
关于为什么需要再次判断前面的节点:当节点由于宕机等原因意外被删后触发了监听,但是此时可能前面还有节点
curator支持的锁
- 共享可重入锁:全局同步的完全分布式锁,这意味着在任何快照上,没有两个客户端会认为它们持有相同的锁。
- 共享锁:与共享可重入锁相似,但不可重入。
- 共享可重入读写锁:可跨JVM使用的可重入读/写互斥量。读写锁维护一对相关联的锁,一个用于只读操作,另一个用于写操作。只要没有写入器,读取锁就可以同时由多个读取器进程持有。写锁是排他的。
- 共享信号量:跨JVM工作的计数信号量。所有JVM中使用相同锁定路径的所有进程都将获得进程间有限的一组租约。此外,这种信号量大多是“公平的”-每个用户都将按照请求的顺序获得租约(从ZK的角度来看)。
- 多共享锁:一种将多个锁作为一个实体进行管理的容器。调用acquire()时,将获取所有锁。如果失败,则释放所有获取的路径。同样,当调用release()时,将释放所有锁(忽略故障)。
Leader 选举在分布式场景中的应用
在服务器启动的时候,去zookeeper特定的一个目录下注册一个临时节点(这个节点作为master,谁注册了这个节点谁就是master),注册的时候,如果发现该节点已经存在,则说明已经有别的服务器注册了(也就是有别的服务器已经抢主成功),那么当前服务器只能放弃抢主,作为从机存在。同时,抢主失败的当前服务器需要订阅该临时节点的删除事件,以便该节点删除时(也就是注册该节点的服务器宕机了或者网络断了之类的)进行再次抢主操作。Maser选举的过程,其实就是简单的争抢在zookeeper注册临时节点的操作,谁注册了约定的临时节点,谁就是master
实现注册中心
比如,User-Service, 那所有的用户服务在启动的时候,都在User-Service 这个节点下面创建一个子节点(临时节点),这个子节点保持唯一就好,代表了每个服务实例的唯一标识,有依赖user-server服务的比如Order-Service 就可以通过User-Service 这个父节点,就能获取所有的User-Service 子节点,并且获取所有的子节点信息(IP,端口等信息),拿到子节点的数据后Order-Service可以对其进行缓存,然后实现一个客户端的负载均衡,同时还可以对这个User-Service 目录进行监听, 这样有新的节点加入,或者退出,Order-Service都能收到通知,这样Order-Service重新获取所有子节点,且进行数据更新。这个用户服务的子节点的类型为临时节点。 Zookeeper中临时节点生命周期是和SESSION绑定的,如果SESSION超时了,对应的节点会被删除,被删除时,Zookeeper 会通知对该节点父节点进行监听的客户端, 这样对应的客户端又可以刷新本地缓存了。当有新服务加入时,同样也会通知对应的客户端,刷新本地缓存,要达到这个目标需要客户端重复的注册对父节点的监听。这样就实现了服务的自动注册和自动退出
Zookeeper分布式一致性协议ZAB
ZAB协议介绍
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 不可用时,则进入崩溃恢复模式。
消息广播与崩溃恢复
消息广播
ZAB 协议的消息广播过程使用的是一个原子广播协议,类似一个 两阶段提交过程。对于客户端发送的写请求,全部由 Leader 接收,Leader 将请求封装成一个事务 Proposal,将其发送给所有 Follwer ,然后,根据所有 Follwer 的反馈,如果超过半数(含leader自己)成功响应,则执行 commit 操作
还有一些细节:
- Leader 在收到客户端请求之后,会将这个请求封装成一个事务,并给这个事务分配一个全局递增的唯一 ID,称为事务ID(ZXID),ZAB 协议需要保证事务的顺序,因此必须将每一个事务按照 ZXID 进行先后排序然后处理,主要通过消息队列实现。
- 在 Leader 和 Follwer 之间还有一个消息队列,用来解耦他们之间的耦合,解除同步阻塞。
- zookeeper集群中为保证任何所有进程能够有序的顺序执行,只能是 Leader 服务器接受写请求,即使是 Follower 服务器接受到客户端的写请求,也会转发到 Leader 服务器进行处理,Follower只能处理读请求。
- ZAB协议规定了如果一个事务在一台机器上被处理(commit)成功,那么应该在所有的机器上都被处理成功,哪怕机器出现故障崩溃。
崩溃恢复
刚刚我们说消息广播过程中,Leader 崩溃怎么办?还能保证数据一致吗?
实际上,当 Leader 崩溃,即进入我们开头所说的崩溃恢复模式(崩溃即:Leader 失去与过半 Follwer 的联系)。下面来详细讲述。
假设1:Leader 在复制数据给所有 Follwer 之后,还没来得及收到Follower的ack返回就崩溃,怎么办?
假设2:Leader 在收到 ack 并提交了自己,同时发送了部分 commit 出去之后崩溃怎么办?
假设1:Leader 在复制数据给所有 Follwer 之后,还没来得及收到Follower的ack返回就崩溃,怎么办?
假设2:Leader 在收到 ack 并提交了自己,同时发送了部分 commit 出去之后崩溃怎么办?
针对这些问题,ZAB 定义了 2 个原则:
- ZAB 协议确保丢弃那些只在 Leader 提出/复制,但没有提交的事务。
- ZAB 协议确保那些已经在 Leader 提交的事务最终会被所有服务器提交。(因为leader提交的数据说明已经发送到follower去了)
所以,ZAB 设计了下面这样一个选举算法:
能够确保提交已经被 Leader 提交的事务,同时丢弃已经被跳过的事务。
针对这个要求,如果让 Leader 选举算法能够保证新选举出来的 Leader 服务器拥有集群中所有机器 ZXID 最大的事务,那么就能够保证这个新选举出来的 Leader 一定具有所有已经提交的提案。
而且这么做有一个好处是:可以省去 Leader 服务器检查事务的提交和丢弃工作的这一步操作。
能够确保提交已经被 Leader 提交的事务,同时丢弃已经被跳过的事务。
针对这个要求,如果让 Leader 选举算法能够保证新选举出来的 Leader 服务器拥有集群中所有机器 ZXID 最大的事务,那么就能够保证这个新选举出来的 Leader 一定具有所有已经提交的提案。
而且这么做有一个好处是:可以省去 Leader 服务器检查事务的提交和丢弃工作的这一步操作。
数据同步
当崩溃恢复之后,需要在正式工作之前(接收客户端请求),Leader 服务器首先确认事务是否都已经被过半的 Follwer 提交了,即是否完成了数据同步。目的是为了保持数据一致。当 Follwer 服务器成功同步之后,Leader 会将这些服务器加入到可用服务器列表中
实际上,Leader 服务器处理或丢弃事务都是依赖着 ZXID 的,那么这个 ZXID 如何生成呢?
高 32 位代表了每代 Leader 的唯一性,低 32 代表了每代 Leader 中事务的唯一性。同时,也能让 Follwer 通过高 32 位识别不同的 Leader。简化了数据恢复流程。
基于这样的策略:当 Follower 连接上 Leader 之后,Leader 服务器会根据自己服务器上最后被提交的 ZXID 和 Follower 上的 ZXID 进行比对,比对结果要么回滚,要么和 Leader 同步。
- 答:在 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 同步。
Zookeeper之Leader选举源码剖析
zookeeper集群模式中的三种角色
Leader: 处理所有的事务请求(写请求),可以处理读请求,集群中只能有一个Leader
Follower:只能处理读请求,同时作为 Leader的候选节点,即如果Leader宕机,Follower节点要参与到新的Leader选举中,有可能成为新的Leader节点。
Observer:只能处理读请求。不能参与选举
启动或leader宕机选举leader流程
选票格式为vote=(myid,zxid),zxid为最大的事务id。事务id越大说明数据是越新,所以越容易被选
第一轮:当刚启动的时候,都是先投自己,如上图:myid为1的机器向其他节点投票(1,0),myid为2的机器向其他节点投票(2,0),然后他们在把自己投的票跟收到的票比较,比较方式先优先选择选举周期大的,再选择zxid大的为leader,zxid一样的默认选择myid大的为leader,所以上图第一轮结果得到票(2,0),此时因为收到的票数都是1,没有过半,所以需要新一轮投票
第二轮:在第二轮中两台机器都会投第一轮比较好的票数出去,也就是(2,0)。此时收到两票(2,0)超过半数,选举(2,0)的机器为leader,选举结束
当有新节点加入时,因为集群中已经存在leader,所以新节点直接为follower
第一轮:当刚启动的时候,都是先投自己,如上图:myid为1的机器向其他节点投票(1,0),myid为2的机器向其他节点投票(2,0),然后他们在把自己投的票跟收到的票比较,比较方式先优先选择选举周期大的,再选择zxid大的为leader,zxid一样的默认选择myid大的为leader,所以上图第一轮结果得到票(2,0),此时因为收到的票数都是1,没有过半,所以需要新一轮投票
第二轮:在第二轮中两台机器都会投第一轮比较好的票数出去,也就是(2,0)。此时收到两票(2,0)超过半数,选举(2,0)的机器为leader,选举结束
当有新节点加入时,因为集群中已经存在leader,所以新节点直接为follower
leader选举多层队列架构
在启动的时候创建本服务自己的WorkerSender线程、WorkerReceiver线程;建立Bio连接,为每个连接创建一个SenderWorker线程、RecvWorker线程
整个zookeeper选举底层可以分为选举应用层和消息传输层,应用层有自己的队列统一接收和发送选票,传输层也设计了自己的队列,但是按发送的机器分了队列,避免给每台机器发送消息时相互影响,比如某台机器如果出问题发送不成功则不会影响对正常机器的消息发送
选举中节点状态:
- LOOKING:正在选举,
- FOLLOWING:从节点,
- LEADING:leader节点,
- OBSERVING:只读节点;
- 应用层选举线程:启动的时候会启动一个leader选举线程,选举未结束,一开始给自己投票并放入sendqueue,再一直循环从recvqueue队列中拿取票据,进行pk,再将pk结果放入sendqueue中。
- 应用层发送WorkerSender线程:不断从sendqueue拿取消息并放入到queueSendMap里对应机器的队列中
- 应用层接受WorkerReceiver线程:不断从recvQueue拿取票据并放入到recvqueue队列中,并且会进行简单的票据pk(比较远端跟本地的选举周期),再将PK结果放入sendqueue中
- 传输层发送SenderWorker线程:从queueSendMap拿到队列,然后通过socket发送队列里的消息出去
- 传输层接受RecvWorker线程:读取远端发送过来的消息并放入recvQueue队列
在启动的时候创建本服务自己的WorkerSender线程、WorkerReceiver线程;建立Bio连接,为每个连接创建一个SenderWorker线程、RecvWorker线程
队列:
- 应用层sendqueue发送队列:
- 应用层recvqueue接收队列:
- 传输层queueSendMap发送队列Map:
- 传输层接收recvQueue队列:
- SenderWorker线程Map:
- RecvWorker线程Map:
当有新节点加入源码逻辑
分为业务层leader选举线程判断、WorkerReceiver线程判断
一种是远端告诉你哪个是leader,一种是远端处理你的自投票据回复给你已选举出的leader
一种是远端告诉你哪个是leader,一种是远端处理你的自投票据回复给你已选举出的leader
业务层处理远端票据消息时判断:
- 远端告诉你哪个是leader
服务刚启动的时候发送自投票后,会从recvqueue队列拿取远端发送过来的票据,如果远端为following或者leader,说明本机是新加入的节点
这种情况一般是已选出leader的集群,有新机器加入的时候,新机器处于Looking状态,会先给自己投票,其他机器收到后会回发已选出的leader票据过来,才来到这个逻辑。这个票据的发送方可能为FOLLOWING或LEADING
WorkerReceiver处理远端票据时判断
- 远端处理你的自投票据回复给你
从recvQueue队列中处理远端票据,收到新加入服务的自投票据
如果本服务器不是looking选举状态,但是远端服务器是looking选举状态,这种情况为收到新加入节点的自投票据,则将本机器认为的leader发给远端服务器,leader已经有了,我直接告诉你就行了,你不用再选了if(ackstate == QuorumPeer.ServerState.LOOKING)
leader宕机触发重新选举源码逻辑
服务启动的时候启动的业务线程(也就是每一台节点都会跟leader同步信息),会一直跟leader同步本机节点信息,如果跟leader建立连接失败则开始新一轮选举
如果发送选票的机器id小于当前机器id,则关闭连接。为什么这么做呢?为了防止机器之间重复建立socket连接(socket连接为双向),zk不允许id小的连接id大的机器。因为每台机器在启动的时候都会主动去跟其他集群节点建立socket连接,本身socket就是双向连接,比如A跟B建立起了连接,那么AB能互相发送消息。B在启动的时候也会向A发起连接,但是因为AB的连接A已经建立好了,所以没必要再建立一条通道,浪费,直接关闭。当然你也可以规定小的连大的,都是一样的,只是zk使用只能大的连小的来限制。
ribbitmq
rabbitmq入门
rabbitmq是一个开源的消息代理和队列服务器,通过普通的协议(Amqp协议)来完成不同应用之间的数据共享(消费生产和消费者 可以跨语言平台) rabbitmq是通过erlang语言来开发的基于amqp协议
为什么选择Rabbitmq
- 开源,性能好,稳定性保证,
- 提供了消息的可靠性投递(confirm /kənˈfɜːm/),返回模式
- 与sping amqp 整合和完美,提供丰富的api
- 集群模式十分丰富(HA模式 镜像队列模型)
- 保证数据不丢失的情况下,保证很好的性能
Rabbitmq高性能是如何做到的
1)使用的语言是erlang语言(通常使用到交互机上),erlang的语言的性能与原生socket的延迟效果.
2) 消息入队的延时以及消息的消费的响应很快
2) 消息入队的延时以及消息的消费的响应很快
什么是AMQP协议(Advanced message queue protocol) 高级消息队列协议
1) 是一个二进制协议,
2)amqp 是一个应用层协议的规范(定义了很多规范),可以有很多不同的消息中间件产品(需要遵循该规范)
server:是消息队列节点
virtual host:虚拟主机
exchange 交换机(消息投递到交换机上)
message queue(被消费者监听消费)
2)amqp 是一个应用层协议的规范(定义了很多规范),可以有很多不同的消息中间件产品(需要遵循该规范)
server:是消息队列节点
virtual host:虚拟主机
exchange 交换机(消息投递到交换机上)
message queue(被消费者监听消费)
AMQP的核心概念
1)server : 又称为broker,接受客户端连接,实现amqp实体服务
2)Connection: 连接,应用程序与brokder建立网络连接
3)channel:信道,多路复用连接中的一条独立的双向数据流通道。信道是建立在真实的TCP连接内地虚拟连接,AMQP 命令都是通过信道发出去的,不管是发布消息、订阅队列还是接收消息,这些动作都是通过信道完成。因为对于操作系统来说建立和销毁 TCP 都是非常昂贵的开销,所以引入了信道的概念,以复用一条 TCP 连接。
4)Message: 服务器和应用程序之间传递数据的载体,有properties(消息属性,用来修饰消息,比如消息的优先级,延时投递)和Body(消息体)
5)virtual host (虚拟主机): 是一个逻辑概念,最上层的消息路由,一个虚拟主机中可以包含多个exhange 和queue 但是一个虚拟主机中不能有名称相同的exchange 和queue
6)exchange 交换机 : 消息直接投递到交换机上,然后交换机根据消息的路由key 来路由到对应绑定的队列上
7)baingding: 绑定 exchange 与queue的虚拟连接,bingding中可以包含route_key
8)route_key 路由key , 他的作用是在交换机上通过route_key来把消息路由到哪个队列上
9)queue:队列, 用于来保存消息的载体,有消费者监听,然后消费消息
2)Connection: 连接,应用程序与brokder建立网络连接
3)channel:信道,多路复用连接中的一条独立的双向数据流通道。信道是建立在真实的TCP连接内地虚拟连接,AMQP 命令都是通过信道发出去的,不管是发布消息、订阅队列还是接收消息,这些动作都是通过信道完成。因为对于操作系统来说建立和销毁 TCP 都是非常昂贵的开销,所以引入了信道的概念,以复用一条 TCP 连接。
4)Message: 服务器和应用程序之间传递数据的载体,有properties(消息属性,用来修饰消息,比如消息的优先级,延时投递)和Body(消息体)
5)virtual host (虚拟主机): 是一个逻辑概念,最上层的消息路由,一个虚拟主机中可以包含多个exhange 和queue 但是一个虚拟主机中不能有名称相同的exchange 和queue
6)exchange 交换机 : 消息直接投递到交换机上,然后交换机根据消息的路由key 来路由到对应绑定的队列上
7)baingding: 绑定 exchange 与queue的虚拟连接,bingding中可以包含route_key
8)route_key 路由key , 他的作用是在交换机上通过route_key来把消息路由到哪个队列上
9)queue:队列, 用于来保存消息的载体,有消费者监听,然后消费消息
Rabbitmq的整体架构模型
AMQP 中的消息路由
声明交换机会自动创建交换机,声明队列会自动创建队列并且绑定交换机。在发送消息的时候如果不存在该routing-key绑定的队列,消息不可达,消息会丢失。交换机跟队列都是独立的,也就是说交换机跟队列都可以有不同的routing-key,消息的流转都是通过routing-key来决定,但是如果消费者指定的交换机跟消息发送的交换机不同,消息是收不到的。简单说:消息直接发送交换机,不同交换机类型(direct-完全匹配、fanout-广播模式、topic-通配符模式)根据routing-key都会有不同的路由规则,路由消息到符合匹配规则的队列中,消费者也就能消费到了
案例:绑定队列,通过键 routingKey 将队列和交换器绑定起来 会自动创建并且绑定交换机
channel.queueBind(queueName, exchangeName, routingKey);
案例:绑定队列,通过键 routingKey 将队列和交换器绑定起来 会自动创建并且绑定交换机
channel.queueBind(queueName, exchangeName, routingKey);
Exchange 类型
direct:消息中的路由键(routing key)如果和 Binding 中的 binding key 一致, 交换器就将消息发到对应的队列中。路由键与队列名完全匹配,如果一个队列绑定到交换机要求路由键为“dog”,则只转发 routing key 标记为“dog”的消息,不会转发“dog.puppy”,也不会转发“dog.guard”等等。它是完全匹配、单播的模式
fanout/fænaʊt/ 广播:每个发到 fanout 类型交换器的消息都会分到所有绑定的队列上去。 交换器不处理路由键,只是简单的将队列绑定到交换器上,每个发送到交换器的消息都会被转发到与该交换器绑定的所有队列上。很像子网广播,每台子网内的主机都获得了一份复制的消息。fanout 类型转发消息是最快的
topic通配符模式:topic 交换器通过模式匹配分配消息的路由键属性,将路由键和某个模式进行匹配,此时队列需要绑定到一个模式上。它将路由键和绑定键的字符串切分成单词,这些单词之间用点隔开。它同样也会识别两个通配符:符号“#”和符号“”。#匹配0个或多个单词,匹配不多不少一个单词
rabbitmq高级特性用法
消息的confirm机制(生产者)
rabbitmq消息的confirm机制--消息可靠性投递的核心保障:
- 消息的确认:生产者将消息投递后,如果mq收到消息,就会返回一个ack应答给生产者;
- 生产者收到ack确认消息,来确保消息发送到了mq_server
confirm机制实现步骤:
- 第一步:在channel 上开启确认模式 channel.confirmSelect();
- 第二步:生产者在channel上添加监听,用来监听mq-server返回的应答
return listener消息处理机制
Return Listener是用来处理一些不可路由的消息,只会处理不可达消息,可达的就算你设置了mandatory设置为true也不会调ReturnListener
我们的消息生产者,通过把消息投递到exchange上,然后通过routingkey 把消息路由到某一个队列上,然后我们
消费者通过队列消息侦听,然后进行消息消费处理。以上会出现的情况
情况一:broker中根本没有对应的exchange交换机来接受该消息
情况二:消息能够投递到broker的交换机上,但是交换机根据routingKey 路由不到某一个队列上.
消费者通过队列消息侦听,然后进行消息消费处理。以上会出现的情况
情况一:broker中根本没有对应的exchange交换机来接受该消息
情况二:消息能够投递到broker的交换机上,但是交换机根据routingKey 路由不到某一个队列上.
处理一:若在消息生产端 的mandatory设置为true 那么就会调用生产端ReturnListener 来处理,
处理二:若消息生产端的mandatory设置为false(默认值也是false) 那么mq-broker就会自动删除消息
处理二:若消息生产端的mandatory设置为false(默认值也是false) 那么mq-broker就会自动删除消息
自定义消费监听--也就是自定义消费者类
更加优雅的消费消息
消费端的ack
消费端的ack类型:自动ack 和手动ack
重回队列:当消费端进行了nack(否定应答)的操作的时候,我们可以通过设置来进行对消息的重回队列的操作( 但是一般我们不会设置重回队列的操作 )
消费端如何做限流量
什么是消费端的限流
场景:首先,我们迎来了订单的高峰期,在mq的broker上堆积了成千上万条消息没有处理,这个时候,我们随便打开了消费者,就会出现下面请如此多的消息瞬间推送给消费者,我们的消费者不能处理这么多消息 就会导致消费者出现巨大压力,甚至服务器崩溃
我觉得消息队列只要自己控制消费者的ack+限制消费者线程其实就算是流控了,因为我处理完一条消息才会处理第二条,但是会一直占着资源
解决方案:rabbitmq 提供一个钟qos(服务质量保证),也就是在 关闭了消费端的自动ack 的前提下,我们可以设置 阈值(出队) 的消息数没有被确认(手动确认)【比如设置成1代表只要有一条消息没ack,服务端则不会推送消息了】,那么就不会推送消息过来,限流的级别(consumer级别或者是channel级别)
死信队列(死信交换机)
什么是死信?
就是在队列中的消息如果没有消费者消费,那么该消息就成为一个死信,那这个消息被重新发送到另外一个exchange上的话,那么后面这个exhcange就是死信队列
就是在队列中的消息如果没有消费者消费,那么该消息就成为一个死信,那这个消息被重新发送到另外一个exchange上的话,那么后面这个exhcange就是死信队列
消息变成死信的几种情况(死信队列也是一个正常的exchange,也会通过routingkey 绑定到具体的队列上。)
- 消息被拒绝 :【basic.reject/basic.nack(否定应答)】并且requeue(重回队列)的属性设置为false 表示不需要重回队列,那么该消息就是一个死信消息
- 消息TTL过期:消息本身设置了过期时间,或者队列设置了消息过期时间;
- x-message-ttl队列达到最大长度: 比如队列最大长度是3000 ,那么3001消息就会被送到死信队列上.
延迟队列(间接实现)
RabbitMQ中的一个高级特性——TTL(Time To Live)。
TTL是什么呢?TTL是RabbitMQ中一个消息或者队列的属性,表明一条消息或者该队列中的所有消息的最大存活时间,单位是毫秒。换句话说,如果一条消息设置了TTL属性或者进入了设置TTL属性的队列,那么这条消息如果在TTL设置的时间内没有被消费,则会成为“死信”。如果同时配置了队列的TTL和消息的TTL,那么较小的那个值将会被使用
TTL是什么呢?TTL是RabbitMQ中一个消息或者队列的属性,表明一条消息或者该队列中的所有消息的最大存活时间,单位是毫秒。换句话说,如果一条消息设置了TTL属性或者进入了设置TTL属性的队列,那么这条消息如果在TTL设置的时间内没有被消费,则会成为“死信”。如果同时配置了队列的TTL和消息的TTL,那么较小的那个值将会被使用
- 第一种是在创建队列的时候设置队列的“x-message-ttl”属性
- 另一种方式便是针对每条消息设置TTL
这个只是rabbitmq的一个过期特性,并不是延迟队列,但是我们能利用上面说的特性来实现延迟队列:
方式一:
想想看,延时队列,不就是想要消息延迟多久被处理吗,TTL则刚好能让消息在延迟多久之后成为死信,另一方面,成为死信的消息都会被投递到死信队列里,这样只需要消费者一直消费死信队列里的消息就万事大吉了,因为里面的消息都是希望被立即处理的消息
生产者生产一条延时消息,根据需要延时时间的不同,利用不同的routingkey将消息路由到不同的延时队列,每个队列都设置了不同的TTL属性,并绑定在同一个死信交换机中,消息过期后,根据routingkey的不同,又会被路由到不同的死信队列中,消费者只需要监听对应的死信队列进行处理即可
方式二:如果不能实现在消息粒度上添加TTL,并使其在设置的TTL时间及时死亡,就无法设计成一个通用的延时队列。
那如何解决这个问题呢?不要慌,安装一个插件即可:https://www.rabbitmq.com/community-plugins.html ,下载rabbitmq_delayed_message_exchange插件,然后解压放置到RabbitMQ的插件目录
其他方式:当然,延时队列还有很多其它选择,比如利用Java的DelayQueu,利用Redis的zset,利用Quartz或者利用kafka的时间轮
方式一:
想想看,延时队列,不就是想要消息延迟多久被处理吗,TTL则刚好能让消息在延迟多久之后成为死信,另一方面,成为死信的消息都会被投递到死信队列里,这样只需要消费者一直消费死信队列里的消息就万事大吉了,因为里面的消息都是希望被立即处理的消息
生产者生产一条延时消息,根据需要延时时间的不同,利用不同的routingkey将消息路由到不同的延时队列,每个队列都设置了不同的TTL属性,并绑定在同一个死信交换机中,消息过期后,根据routingkey的不同,又会被路由到不同的死信队列中,消费者只需要监听对应的死信队列进行处理即可
方式二:如果不能实现在消息粒度上添加TTL,并使其在设置的TTL时间及时死亡,就无法设计成一个通用的延时队列。
那如何解决这个问题呢?不要慌,安装一个插件即可:https://www.rabbitmq.com/community-plugins.html ,下载rabbitmq_delayed_message_exchange插件,然后解压放置到RabbitMQ的插件目录
其他方式:当然,延时队列还有很多其它选择,比如利用Java的DelayQueu,利用Redis的zset,利用Quartz或者利用kafka的时间轮
rabbitmq消息可靠性投递
1、先把消息保存在库中(需保持跟分支在同一个事务)
2、消息发送成功更新库中消息状态为1,否则为2
3、消费者消费消息,消费成功更新库中消息状态为3,否则更新为4
4、后台定时任务定时重发消息状态不为3的消息,重试次数+1,如果重试次数过大则人工补偿
2、消息发送成功更新库中消息状态为1,否则为2
3、消费者消费消息,消费成功更新库中消息状态为3,否则更新为4
4、后台定时任务定时重发消息状态不为3的消息,重试次数+1,如果重试次数过大则人工补偿
kafka
kafka集群搭建与入门详解
Kafka的使用场景
- 日志收集:一个公司可以用Kafka收集各种服务的log,通过kafka以统一接口服务的方式开放给各种consumer,例如hadoop、Hbase、Solr等。
- 消息系统:解耦和生产者和消费者、缓存消息等。
- 用户活动跟踪:Kafka经常被用来记录web用户或者app用户的各种活动,如浏览网页、搜索、点击等活动,这些活动信息被各个服务器发布到kafka的topic中,然后订阅者通过订阅这些topic来做实时的监控分析,或者装载到hadoop、数据仓库中做离线分析和挖掘。
- 运营指标:Kafka也经常用来记录运营监控数据。包括收集各种分布式应用的数据,生产各种操作的集中反馈,比如报警和报告
Kafka基本概念
- Broker :消息中间件处理节点,一个Kafka节点就是一个broker,一个或者多个Broker可以组成一个Kafka集群
- Topic :Kafka根据topic对消息进行归类,发布到Kafka集群的每条消息都需要指定一个topic
- Producer :消息生产者,向Broker发送消息的客户端
- Consumer :消息消费者,从Broker读取消息的客户端
- ConsumerGroup :每个Consumer属于一个特定的Consumer Group,一条消息可以被多个不同的Consumer Group消费,但是一个Consumer Group中只能有一个Consumer能够消费该消息
- Partition :物理上的概念,一个topic可以分为多个partition,每个partition内部消息是有序的
主题Topic和消息日志Log
可以理解Topic是一个类别的名称,同类消息发送到同一个Topic下面。对于每一个Topic,下面可以有多个分区(Partition)日志文件
Partition是一个有序的message序列,这些message按顺序添加到一个叫做commit log的文件中。每个partition中的消息都有一个唯一的编号,称之为offset,用来唯一标示某个分区中的message
提示:每个partition,都对应一个commit log文件。一个partition中的message的offset都是唯一的,但是不同的partition中的message的offset可能是相同的
提示:每个partition,都对应一个commit log文件。一个partition中的message的offset都是唯一的,但是不同的partition中的message的offset可能是相同的
可以这么来理解Topic,Partition和Broker
一个topic,代表逻辑上的一个业务数据集,比如按数据库里不同表的数据操作消息区分放入不同topic,订单相关操作消息放入订单topic,用户相关操作消息放入用户topic,对于大型网站来说,后端数据都是海量的,订单消息很可能是非常巨量的,比如有几百个G甚至达到TB级别,如果把这么多数据都放在一台机器上定会有容量限制问题,那么就可以在topic内部划分多个partition来分片存储数据,不同的partition可以位于不同的机器上,每台机器上都运行一个Kafka的进程Broker
一个topic,代表逻辑上的一个业务数据集,比如按数据库里不同表的数据操作消息区分放入不同topic,订单相关操作消息放入订单topic,用户相关操作消息放入用户topic,对于大型网站来说,后端数据都是海量的,订单消息很可能是非常巨量的,比如有几百个G甚至达到TB级别,如果把这么多数据都放在一台机器上定会有容量限制问题,那么就可以在topic内部划分多个partition来分片存储数据,不同的partition可以位于不同的机器上,每台机器上都运行一个Kafka的进程Broker
kafka集群,在配置的时间范围内,维护所有的由producer生成的消息,而不管这些消息有没有被消费。例如日志保留( log retention )时间被设置为2天。kafka会维护最近2天生产的所有消息,而2天前的消息会被丢弃。kafka的性能与保留的数据量的大小没有关系,因此保存大量的数据(日志信息)不会有什么影响(又不是存在一起,我分开存到不同的partition上,所以性能不关这个事)。
每个consumer是基于自己在commit log中的消费进度(offset)来进行工作的。在kafka中,消费offset由consumer自己来维护;一般情况下我们按照顺序逐条消费commit log中的消息,当然我可以通过指定offset来重复消费某些消息,或者跳过某些消息。
这意味kafka中的consumer对集群的影响是非常小的,添加一个或者减少一个consumer,对于集群或者其他consumer来说,都是没有影响的,因为每个consumer维护各自的offset。所以说kafka集群是无状态的,性能不会因为consumer数量受太多影响。kafka还将很多关键信息记录在zookeeper里,保证自己的无状态,从而在水平扩容时非常方便
每个consumer是基于自己在commit log中的消费进度(offset)来进行工作的。在kafka中,消费offset由consumer自己来维护;一般情况下我们按照顺序逐条消费commit log中的消息,当然我可以通过指定offset来重复消费某些消息,或者跳过某些消息。
这意味kafka中的consumer对集群的影响是非常小的,添加一个或者减少一个consumer,对于集群或者其他consumer来说,都是没有影响的,因为每个consumer维护各自的offset。所以说kafka集群是无状态的,性能不会因为consumer数量受太多影响。kafka还将很多关键信息记录在zookeeper里,保证自己的无状态,从而在水平扩容时非常方便
为什么要对Topic下数据进行分区存储?
1、commit log文件会受到所在机器的文件系统大小的限制,分区之后,理论上一个topic可以处理任意数量的数据。
2、为了提高并行度(不同的consumer可以消费不同的分区上的消息)
1、commit log文件会受到所在机器的文件系统大小的限制,分区之后,理论上一个topic可以处理任意数量的数据。
2、为了提高并行度(不同的consumer可以消费不同的分区上的消息)
kafka只有异步刷盘:Kafka的日志实际上是开始是在缓存中的,然后根据策略定期一批一批写入到日志文件中去,以提高吞吐率
分布式Distribution
partitions分布在kafka集群中不同的broker上,每个broker可以请求备份其他broker上partition上的数据。kafka集群支持配置一个partition备份的数量。
针对每个partition,都有一个broker起到“leader”的作用,0个或多个其他的broker作为“follwers”的作用。leader处理所有的针对这个partition的读写请求(副本不能写也不能读),而followers被动复制leader的结果(就是从leader消费过去然后保存的方法)。如果这个leader失效了,其中的一个follower将会自动的变成新的leader
针对每个partition,都有一个broker起到“leader”的作用,0个或多个其他的broker作为“follwers”的作用。leader处理所有的针对这个partition的读写请求(副本不能写也不能读),而followers被动复制leader的结果(就是从leader消费过去然后保存的方法)。如果这个leader失效了,其中的一个follower将会自动的变成新的leader
Producers
生产者将消息发送到topic中去,同时负责选择将message发送到topic的哪一个partition中。
生产者将消息发送到topic中去,同时负责选择将message发送到topic的哪一个partition中。
- 通过round-robin做简单的负载均衡。
- 也可以根据消息中的某一个关键字来进行区分。通常第二种方式使用的更多
Consumers
传统的消息传递模式有2种:队列( queue) 和(publish-subscribe)
Kafka基于这2种模式提供了一种consumer的抽象概念:consumer group。
上图说明:由2个broker组成的kafka集群,总共有4个partition(P0-P3)。这个集群由2个Consumer Group, A有2个consumer instances ,B有四个。
通常一个topic会有几个consumer group,每个consumer group都是一个逻辑上的订阅者( logical subscriber )。每个consumer group由多个consumer instance组成,从而达到可扩展和容灾的功能。
传统的消息传递模式有2种:队列( queue) 和(publish-subscribe)
- queue模式:多个consumer从服务器中读取数据,消息只会到达一个consumer。
- publish-subscribe模式:消息会被广播给所有的consumer。
Kafka基于这2种模式提供了一种consumer的抽象概念:consumer group。
- queue模式:所有的consumer都位于同一个consumer group 下。
- publish-subscribe模式:所有的consumer都有着自己唯一的consumer group。
上图说明:由2个broker组成的kafka集群,总共有4个partition(P0-P3)。这个集群由2个Consumer Group, A有2个consumer instances ,B有四个。
通常一个topic会有几个consumer group,每个consumer group都是一个逻辑上的订阅者( logical subscriber )。每个consumer group由多个consumer instance组成,从而达到可扩展和容灾的功能。
消费顺序
Kafka比传统的消息系统有着更强的顺序保证。
一个partition同一个时刻在一个consumer group中只有一个consumer instance在消费,从而保证顺序。
consumer group中的consumer instance的数量不能比所在Topic中的partition的数量多,否则,多出来的consumer消费不到消息。
Kafka只在partition的范围内保证消息消费的局部顺序性,不能在同一个topic中的多个partition中保证总的消费顺序性。
如果有在总体上保证消费顺序的需求,那么我们可以通过将topic的partition数量设置为1,将consumer group中的consumer instance数量也设置为1
一个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提供了以下的保证:
发送到一个Topic中的message会按照发送的顺序添加到commit log中。意思是,如果消息 M1,M2由同一个producer发送,M1比M2发送的早的话,那么在commit log中,M1的offset就会比commit 2的offset小。
一个consumer在commit log中可以按照发送顺序来消费message。
如果一个topic的备份因子设置为N,那么Kafka可以容忍N-1个服务器的失败,而存储在commit log中的消息不会丢失
发送到一个Topic中的message会按照发送的顺序添加到commit log中。意思是,如果消息 M1,M2由同一个producer发送,M1比M2发送的早的话,那么在commit log中,M1的offset就会比commit 2的offset小。
一个consumer在commit log中可以按照发送顺序来消费message。
如果一个topic的备份因子设置为N,那么Kafka可以容忍N-1个服务器的失败,而存储在commit log中的消息不会丢失
其他
kafka本地线程会从缓冲区取数据,批量发送到broker,设置批量发送消息的大小,默认值是16384,即16kb,就是说一个batch满了16kb就发送出去
默认值是0,意思就是消息必须立即被发送,但这样会影响性能
一般设置100毫秒左右,就是说这个消息发送完后会进入本地的一个batch,如果100毫秒内,这个batch满了16kb就会随batch一起被发送出去
如果100毫秒内,batch没满,那么也必须把消息发送出去,不能让消息的发送延迟时间太长
一般设置100毫秒左右,就是说这个消息发送完后会进入本地的一个batch,如果100毫秒内,这个batch满了16kb就会随batch一起被发送出去
如果100毫秒内,batch没满,那么也必须把消息发送出去,不能让消息的发送延迟时间太长
消息压缩:
//把发送的key从字符串序列化为字节数组
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
//把发送消息value从字符串序列化为字节数组
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
//把发送的key从字符串序列化为字节数组
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
//把发送消息value从字符串序列化为字节数组
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
生产端支持同步发送与异步发送
kafka的注册中心是zk,后期版本已经自带,可以不用单独下载zk,直接使用kafka里的就好
kafka设计原理详解
Kafka核心总控制器Controller
在Kafka集群中会有一个或者多个broker,其中有一个broker会被选举为控制器(Kafka Controller),它负责管理整个集群中所有分区和副本的状态。
- 当某个分区的leader副本出现故障时,由控制器负责为该分区选举新的leader副本。
- 当检测到某个分区的ISR集合(replicas副本列表的一个子集,它只列出当前还存活着的,并且已同步备份了该partition的节点)发生变化时,由控制器负责通知所有broker更新其元数据信息。
- 当使用kafka-topics.sh脚本为某个topic增加分区数量时,同样还是由控制器负责分区的重新分配(非脚本的时候并不是由controller来负载Rebalance)
Controller选举机制
在kafka集群启动的时候,会自动选举一台broker作为controller来管理整个集群,选举的过程是集群中每个broker都会尝试在zookeeper上创建一个 /controller 临时节点,zookeeper会保证有且仅有一个broker能创建成功,这个broker就会成为集群的总控器controller。
当这个controller角色的broker宕机了,此时zookeeper临时节点会消失,集群里其他broker会一直监听这个临时节点,发现临时节点消失了,就竞争再次创建临时节点,就是我们上面说的选举机制,zookeeper又会保证有一个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会从每个parititon的 replicas 副本列表中取出第一个broker作为leader,当然这个broker需要也同时在ISR列表里。分区中的所有副本统称为 AR,ISR 集合是 AR 集合的一个子集
消费者消费消息的offset记录机制
每个consumer会定期将自己消费分区的offset提交给kafka内部topic:__consumer_offsets,提交过去的时候,key是consumerGroupId+topic+分区号,value就是当前offset的值,kafka会定期清理topic里的消息,最后就保留最新的那条数据
因为__consumer_offsets可能会接收高并发的请求,kafka默认给其分配50个分区(可以通过offsets.topic.num.partitions设置),这样可以通过加机器的方式抗大并发
因为__consumer_offsets可能会接收高并发的请求,kafka默认给其分配50个分区(可以通过offsets.topic.num.partitions设置),这样可以通过加机器的方式抗大并发
消费者Rebalance机制
消费者rebalance就是说如果consumer group中某个消费者挂了,此时会自动把分配给他的分区交给其他的消费者,如果他又重启了,那么又会把一些分区重新交还给他
如下情况可能会触发消费者rebalance
如下情况可能会触发消费者rebalance
- consumer所在服务重启或宕机了
- 动态给topic增加了分区
- 消费组订阅了更多的topic
Rebalance(重平衡)过程
Rebalance的时候会停止所有kafka服务,因为当前版本的Kafka触发Rebalance时候会重新分配所有的Consumer对应的分区,并不是像一致性哈希(一致性Hash算法对于节点的增减都只需重定位环空间中的一小部分数据,具有较好的容错性和可扩展性。)那样尽量保证其他节点不影响。所以要尽量避免发生Rebalance的发生
第一阶段:选择组协调器
组协调器GroupCoordinator:每个consumer group都会选择一个broker作为自己的组协调器coordinator,负责监控这个消费组里的所有消费者的心跳,以及判断是否宕机,然后开启消费者rebalance。
consumer group中的每个consumer启动时会向kafka集群中的某个节点发送 FindCoordinatorRequest 请求来查找对应的组协调器GroupCoordinator,并跟其建立网络连接。
组协调器选择方式:通过如下公式可以选出__consumer_offsets的哪个分区(比如分区10),这个分区leader对应的broker就是这个consumer group的coordinator
公式:hash(consumer group id) % __consumer_offsets主题的分区数
第二阶段:加入消费组JOIN GROUP
所有消费者会向 GroupCoordinator 发送 JoinGroupRequest 请求。GroupCoordinator 从中选择一个consumer担任group的leader消费组协调器(第一个加入group的consumer作为leader),GroupCoordinator 把consumer group所有成员信息以及他们的订阅信息发送给这个leader,接着这个leader会负责制定分区方案。
第三阶段:同步更新分配方案( SYNC GROUP)
consumer leader会把这个分配方案封装进SyncGroupRequest请求并发送给GroupCoordinator,组内所有成员都会发送SyncGroup请求,接着GroupCoordinator就把属于每个consumer的分区方案作为SyncGroupRequest请求的response返还给consumer,他们会根据指定分区的leader broker进行网络连接以及消息消费。
组协调器GroupCoordinator:每个consumer group都会选择一个broker作为自己的组协调器coordinator,负责监控这个消费组里的所有消费者的心跳,以及判断是否宕机,然后开启消费者rebalance。
consumer group中的每个consumer启动时会向kafka集群中的某个节点发送 FindCoordinatorRequest 请求来查找对应的组协调器GroupCoordinator,并跟其建立网络连接。
组协调器选择方式:通过如下公式可以选出__consumer_offsets的哪个分区(比如分区10),这个分区leader对应的broker就是这个consumer group的coordinator
公式:hash(consumer group id) % __consumer_offsets主题的分区数
第二阶段:加入消费组JOIN GROUP
所有消费者会向 GroupCoordinator 发送 JoinGroupRequest 请求。GroupCoordinator 从中选择一个consumer担任group的leader消费组协调器(第一个加入group的consumer作为leader),GroupCoordinator 把consumer group所有成员信息以及他们的订阅信息发送给这个leader,接着这个leader会负责制定分区方案。
第三阶段:同步更新分配方案( SYNC GROUP)
consumer leader会把这个分配方案封装进SyncGroupRequest请求并发送给GroupCoordinator,组内所有成员都会发送SyncGroup请求,接着GroupCoordinator就把属于每个consumer的分区方案作为SyncGroupRequest请求的response返还给consumer,他们会根据指定分区的leader broker进行网络连接以及消息消费。
Rebalance分区分配策略
主要有三种rebalance的策略:range、round-robin、sticky
Kafka 提供了消费者客户端参数partition.assignment.strategy 来设置消费者与订阅主题之间的分区分配策略。默认情况为range分配策略。
range策略:就是算出平均分配多少,直接一次性分配,多余的平均分配给前面的消费者。比如有10个分区(0-9),3个消费者:分区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策略:就是在rebalance的时候,需要保证如下两个原则。
1)分区的分配要尽可能均匀 。
2)分区的分配尽可能与上次分配的保持相同。
当两者发生冲突时,第一个目标优先于第二个目标 。这样可以最大程度维持原来的分区分配的策略。
比如对于第一种range情况的分配,如果第三个consumer挂了,那么重新用sticky策略分配的结果如下:
consumer1除了原有的0~3,会再分配一个7
consumer2除了原有的4~6,会再分配8和9
Kafka 提供了消费者客户端参数partition.assignment.strategy 来设置消费者与订阅主题之间的分区分配策略。默认情况为range分配策略。
range策略:就是算出平均分配多少,直接一次性分配,多余的平均分配给前面的消费者。比如有10个分区(0-9),3个消费者:分区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策略:就是在rebalance的时候,需要保证如下两个原则。
1)分区的分配要尽可能均匀 。
2)分区的分配尽可能与上次分配的保持相同。
当两者发生冲突时,第一个目标优先于第二个目标 。这样可以最大程度维持原来的分区分配的策略。
比如对于第一种range情况的分配,如果第三个consumer挂了,那么重新用sticky策略分配的结果如下:
consumer1除了原有的0~3,会再分配一个7
consumer2除了原有的4~6,会再分配8和9
producer/prəˈduːsər/发布消息机制剖析
写入方式:producer 采用 push 模式将消息发布到 broker,每条消息都被 append 到 patition 中,属于顺序写磁盘(顺序写磁盘效率比随机写内存要高,保障 kafka 吞吐率)
消息路由(producer负载)
producer 发送消息到 broker 时,会根据分区算法选择将其存储到哪一个 partition。其路由机制为:
1. 指定了 patition,则直接使用;
2. 未指定 patition 但指定 key,通过对 key 的 value 进行hash 选出一个 patition
3. patition 和 key 都未指定,使用轮询选出一个 patition。
producer 发送消息到 broker 时,会根据分区算法选择将其存储到哪一个 partition。其路由机制为:
1. 指定了 patition,则直接使用;
2. 未指定 patition 但指定 key,通过对 key 的 value 进行hash 选出一个 patition
3. patition 和 key 都未指定,使用轮询选出一个 patition。
写入流程
1. producer 先从 zookeeper 的 "/brokers/.../state" 节点找到该 partition 的 leader
2. producer 将消息发送给该 leader
3. leader 将消息写入本地 log
4. followers 从 leader pull 消息,写入本地 log 后 向leader 发送 ACK
5. leader 收到所有 ISR 中的 replica(副本) 的 ACK 后,增加HW(high watermark,最后 commit 的 offset) 并向 producer 发送 ACK(如果设置的acks=-1或all)
1. producer 先从 zookeeper 的 "/brokers/.../state" 节点找到该 partition 的 leader
2. producer 将消息发送给该 leader
3. leader 将消息写入本地 log
4. followers 从 leader pull 消息,写入本地 log 后 向leader 发送 ACK
5. leader 收到所有 ISR 中的 replica(副本) 的 ACK 后,增加HW(high watermark,最后 commit 的 offset) 并向 producer 发送 ACK(如果设置的acks=-1或all)
HW与LEO详解
HW俗称高水位,HighWatermark的缩写,取一个partition对应的ISR中最小的LEO(log-end-offset)作为HW,consumer最多只能消费到该HW所在的位置。另外每个replica都有HW,leader和follower各自负责更新自己的HW的状态。对于leader新写入的消息,consumer不能立刻消费,leader会等待该消息被所有ISR中的replicas同步后更新HW,此时消息才能被consumer消费。这样就保证了如果leader所在的broker失效,该消息仍然可以从新选举的leader中获取。对于来自内部broker的读取请求,没有HW的限制
leader HW:更新时会比较所有满足条件的副本的LEO,包括自己的LEO和remote LEO,选取最小值作为更新后的leader HW
follower HW:更新发生在follower副本更新LEO之后,一旦follower向log写完数据,它就会尝试更新HW值。比较自己的LEO值与fetch响应中leader副本的HW值,取最小者作为follower副本的HW值。可以看出,如果follower的LEO值超过了leader的HW值,那么follower HW值是不会超过leader HW值的
follower HW:更新发生在follower副本更新LEO之后,一旦follower向log写完数据,它就会尝试更新HW值。比较自己的LEO值与fetch响应中leader副本的HW值,取最小者作为follower副本的HW值。可以看出,如果follower的LEO值超过了leader的HW值,那么follower HW值是不会超过leader HW值的
图详细的说明了当producer生产消息至broker后,ISR以及HW和LEO的流转过程:
由此可见,Kafka的复制机制既不是完全的同步复制,也不是单纯的异步复制。事实上,同步复制要求所有能工作的follower都复制完,这条消息才会被commit,这种复制方式极大的影响了吞吐率。而异步复制方式下,follower异步的从leader复制数据,数据只要被leader写入log就被认为已经commit,这种情况下如果follower都还没有复制完,落后于leader时,突然leader宕机,则会丢失数据。而Kafka的这种使用ISR的方式则很好的均衡了确保数据不丢失以及吞吐率
日志分段存储
Kafka 一个分区的消息数据对应存储在一个文件夹下,以topic名称+分区号命名,kafka规定了一个分区内的 .log 文件最大为 1G,做这个限制目的是为了方便把 .log 加载到内存去操作(太大了加载慢)
# 部分消息的offset索引文件,kafka每次往分区发4K(可配置)消息就会记录一条当前消息的offset到index文件,所有文件名代表起始offset
# 如果要定位消息的offset会先在这个文件里快速定位,再去log文件里找具体消息
00000000000000000000.index
# 消息存储文件,主要存offset和消息体
00000000000000000000.log
# 消息的发送时间索引文件,kafka每次往分区发4K(可配置)消息就会记录一条当前消息的发送时间戳与对应的offset到timeindex文件,
# 如果需要按照时间来定位消息的offset,会先在这个文件里查找
00000000000000000000.timeindex
# 如果要定位消息的offset会先在这个文件里快速定位,再去log文件里找具体消息
00000000000000000000.index
# 消息存储文件,主要存offset和消息体
00000000000000000000.log
# 消息的发送时间索引文件,kafka每次往分区发4K(可配置)消息就会记录一条当前消息的发送时间戳与对应的offset到timeindex文件,
# 如果需要按照时间来定位消息的offset,会先在这个文件里查找
00000000000000000000.timeindex
Kafka Broker 有一个参数,log.segment.bytes,限定了每个日志段文件的大小,最大就是 1GB
一个日志段文件满了,就自动开一个新的日志段文件来写入,避免单个文件过大,影响文件的读写性能,这个过程叫做 log rolling,正在被写入的那个日志段文件,叫做 active log segment
Kafka性能优化最佳实践
消息丢失情况
消息发送端
(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直接宕机了,未处理完的数据丢失了,下次也消费不到了
消息重复消费
消息发送端:
发送消息如果配置了重试机制,比如网络抖动时间过长导致发送端发送超时,实际broker可能已经接收到消息,但发送方会重新发送消息
发送消息如果配置了重试机制,比如网络抖动时间过长导致发送端发送超时,实际broker可能已经接收到消息,但发送方会重新发送消息
消息消费端:
如果消费这边配置的是自动提交,刚拉取了一批数据处理了一部分,但还没来得及提交,服务挂了,下次重启又会拉取相同的一批数据重复处理一般消费端都是要做消费幂等处理的。
如果消费这边配置的是自动提交,刚拉取了一批数据处理了一部分,但还没来得及提交,服务挂了,下次重启又会拉取相同的一批数据重复处理一般消费端都是要做消费幂等处理的。
消息乱序
如果发送端配置了重试机制,kafka不会等之前那条消息完全发送成功才去发送下一条消息,这样可能会出现,发送了1,2,3条消息,第一条超时了,后面两条发送成功,再重试发送第1条消息,这时消息在broker端的顺序就是2,3,1了所以,是否一定要配置重试要根据业务情况而定
消息积压
1)线上有时因为发送方发送消息速度过快,或者消费方处理消息过慢,可能会导致broker积压大量未消费消息。此种情况如果积压了上百万未消费消息需要紧急处理,可以修改消费端程序,让其将收到的消息快速转发到其他topic(可以设置很多分区),然后再启动多个消费者同时消费新主题的不同分区。
2)由于消息数据格式变动或消费者程序有bug,导致消费者一直消费不成功,也可能导致broker积压大量未消费消息。此种情况可以将这些消费不成功的消息转发到其它队列里去(类似死信队列),后面再慢慢分析死信队列里的消息处理问题
2)由于消息数据格式变动或消费者程序有bug,导致消费者一直消费不成功,也可能导致broker积压大量未消费消息。此种情况可以将这些消费不成功的消息转发到其它队列里去(类似死信队列),后面再慢慢分析死信队列里的消息处理问题
延时队列
延时队列存储的对象是延时消息。所谓的“延时消息”是指消息被发送以后,并不想让消费者立刻获取,而是等待特定的时间后,消费者才能获取这个消息进行消费,延时队列的使用场景有很多, 比如 :
1)在订单系统中, 一个用户下单之后通常有 30 分钟的时间进行支付,如果 30 分钟之内没有支付成功,那么这个订单将进行异常处理,这时就可以使用延时队列来处理这些订单了。
2)订单完成1小时后通知用户进行评价
1)在订单系统中, 一个用户下单之后通常有 30 分钟的时间进行支付,如果 30 分钟之内没有支付成功,那么这个订单将进行异常处理,这时就可以使用延时队列来处理这些订单了。
2)订单完成1小时后通知用户进行评价
实现思路:发送延时消息时先把消息按照不同的延迟时间段发送到指定的队列中(topic_1s,topic_5s,topic_10s,...topic_2h,这个一般不能支持任意时间段的延时),然后通过定时器进行轮训消费这些topic,查看消息是否到期,如果到期就把这个消息发送到具体业务处理的topic中,队列中消息越靠前的到期时间越早,具体来说就是定时器在一次消费过程中,对消息的发送时间做判断,看下是否延迟到对应时间了,如果到了就转发,如果还没到这一次定时任务就可以提前结束了
消息回溯
如果某段时间对已消费消息计算的结果觉得有问题,可能是由于程序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
问题整理
Kafka的用途有哪些?使用场景如何?
1、日志收集:一个公司可以用Kafka可以收集各种服务的log,通过kafka以统一接口服务的方式开放给各种consumer,例如Hadoop、Hbase、Solr等
2、消息系统:解耦和生产者和消费者、缓存消息等
3、用户活动跟踪:Kafka经常被用来记录web用户或者app用户的各种活动,如浏览网页、搜索、点击等活动,这些活动信息被各个服务器发布到kafka的topic中,然后订阅者通过订阅这些topic来做实时的监控分析,或者装载到Hadoop、数据仓库中做离线分析和挖掘
4、运营指标:Kafka也经常用来记录运营监控数据。包括收集各种分布式应用的数据,生产各种操作的集中反馈,比如报警和报告
5、流式处理:比如spark streaming和storm
6、事件源
2、消息系统:解耦和生产者和消费者、缓存消息等
3、用户活动跟踪:Kafka经常被用来记录web用户或者app用户的各种活动,如浏览网页、搜索、点击等活动,这些活动信息被各个服务器发布到kafka的topic中,然后订阅者通过订阅这些topic来做实时的监控分析,或者装载到Hadoop、数据仓库中做离线分析和挖掘
4、运营指标:Kafka也经常用来记录运营监控数据。包括收集各种分布式应用的数据,生产各种操作的集中反馈,比如报警和报告
5、流式处理:比如spark streaming和storm
6、事件源
Kafka中的ISR、AR又代表什么?ISR的伸缩又指什么
1、分区中的所有副本统称为AR(Assigned Repllicas)。所有与leader副本保持一定程度同步的副本(包括Leader)组成ISR(In-Sync Replicas),ISR集合是AR集合中的一个子集。与leader副本同步滞后过多的副本(不包括leader)副本,组成OSR(Out-Sync Relipcas),由此可见:AR=ISR+OSR。
2、ISR集合的副本必须满足:
副本所在节点必须维持着与zookeeper的连接;
副本最后一条消息的offset与leader副本最后一条消息的offset之间的差值不能超出指定的阈值
3、每个分区的leader副本都会维护此分区的ISR集合,写请求首先由leader副本处理,之后follower副本会从leader副本上拉取写入的消息,这个过程会有一定的延迟,导致follower副本中保存的消息略少于leader副本,只要未超出阈值都是可以容忍的
4、ISR的伸缩指的是Kafka在启动的时候会开启两个与ISR相关的定时任务,名称分别为“isr-expiration"和”isr-change-propagation".。isr-expiration任务会周期性的检测每个分区是否需要缩减其ISR集合。
Kafka中的HW、LEO、LSO、LW等分别代表什么?
1、HW是High Watermak的缩写,俗称高水位,它表示了一个特定消息的偏移量(offset),消费之只能拉取到这个offset之前的消息。
2、LEO是Log End Offset的缩写,它表示了当前日志文件中下一条待写入消息的offset。
3、LSO特指LastStableOffset。它具体与kafka的事物有关。消费端参数——isolation.level,这个参数用来配置消费者事务的隔离级别。字符串类型,“read_uncommitted”和“read_committed”。
4、 LW是Low Watermark的缩写,俗称“低水位”,代表AR集合中最小的logStartOffset值,副本的拉取请求(FetchRequest)和删除请求(DeleteRecordRequest)都可能促使LW的增长。
2、LEO是Log End Offset的缩写,它表示了当前日志文件中下一条待写入消息的offset。
3、LSO特指LastStableOffset。它具体与kafka的事物有关。消费端参数——isolation.level,这个参数用来配置消费者事务的隔离级别。字符串类型,“read_uncommitted”和“read_committed”。
4、 LW是Low Watermark的缩写,俗称“低水位”,代表AR集合中最小的logStartOffset值,副本的拉取请求(FetchRequest)和删除请求(DeleteRecordRequest)都可能促使LW的增长。
Kafka中是怎么体现消息顺序性的?
1、全局有序:一个 topic,一个 partition,一个 consumer,内部单线程消费,单线程吞吐量太低,一般不会用这个。
2、局部有序:写 N 个内存 queue,具有相同 key 的数据都到同一个内存 queue;然后对于 N 个线程,每个线程分别消费一个内存 queue 即可,这样就能保证顺序性
2、局部有序:写 N 个内存 queue,具有相同 key 的数据都到同一个内存 queue;然后对于 N 个线程,每个线程分别消费一个内存 queue 即可,这样就能保证顺序性
Kafka中的分区器、序列化器、拦截器是否了解?它们之间的处理顺序是什么?
拦截器 -> 序列化器 -> 分区器
补充:Producer拦截器(interceptor)是在Kafka 0.10版本被引入的,主要用于实现clients端的定制化控制逻辑。producer允许用户指定多个interceptor按序作用于同一条消息从而形成一个拦截链(interceptor chain)。Intercetpor的实现接口是org.apache.kafka.clients.producer.ProducerInterceptor。实现下面四个方法
1)、configure(configs):获取配置信息和初始化数据时调用
2)、onSend(ProducerRecord):用户可以在该方法中对消息做任何操作,但最好保证不要修改消息所属的topic和分区,否则会影响目标分区的计算
3)、onAcknowledgement(RecordMetadata, Exception):该方法会在消息被应答或消息发送失败时调用
4)、close:关闭interceptor,主要用于执行一些资源清理工作
补充:Producer拦截器(interceptor)是在Kafka 0.10版本被引入的,主要用于实现clients端的定制化控制逻辑。producer允许用户指定多个interceptor按序作用于同一条消息从而形成一个拦截链(interceptor chain)。Intercetpor的实现接口是org.apache.kafka.clients.producer.ProducerInterceptor。实现下面四个方法
1)、configure(configs):获取配置信息和初始化数据时调用
2)、onSend(ProducerRecord):用户可以在该方法中对消息做任何操作,但最好保证不要修改消息所属的topic和分区,否则会影响目标分区的计算
3)、onAcknowledgement(RecordMetadata, Exception):该方法会在消息被应答或消息发送失败时调用
4)、close:关闭interceptor,主要用于执行一些资源清理工作
Kafka生产者客户端的整体结构是什么样子的?使用了几个线程来处理?分别是什么?
子主题
消费组中的消费者个数如果超过topic的分区,那么就会有消费者消费不到数据”这句话是否正确?如果不正确,那么有没有什么hack的手段?
不正确。开发者可以继承AbstractPartitionAssignor实现自定义消费策略,从而实现同一消费组内的任意消费者都可以消费订阅主题的所有分区
消费者提交消费位移时提交的是当前消费到的最新消息的offset还是offset+1?
offset+1
有哪些情形会造成重复消费?
生产者重试跟消费者重试
那些情景下会造成消息漏消费?
先提交offset,后消费,有可能造成数据的重复
Kafka Consumer是非线程安全的,那么怎么样实现多线程消费?
1 、每个线程维护一个KafkaConsumer
2 、维护一个或多个KafkaConsumer,同时维护多个事件处理线程(worker thread)
2 、维护一个或多个KafkaConsumer,同时维护多个事件处理线程(worker thread)
简述消费者与消费组之间的关系
消费者从属与消费组,消费偏移以消费组为单位。每个消费组可以独立消费主题的所有数据,同一消费组内消费者共同消费主题数据,每个分区只能被同一消费组内一个消费者消费。
当你使用kafka-topics.sh创建(删除)了一个topic之后,Kafka背后会执行什么逻辑?
1)会在zookeeper中的/brokers/topics节点下创建一个新的topic节点,如:/brokers/topics/first
2)触发Controller的监听程序
3)kafka Controller 负责topic的创建工作,并更新集群的元数据信息,同步到其他普通的broker节点中
2)触发Controller的监听程序
3)kafka Controller 负责topic的创建工作,并更新集群的元数据信息,同步到其他普通的broker节点中
topic的分区数可不可以增加?如果可以怎么增加?如果不可以,那又是为什么?
可以增加
bin/kafka-topics.sh --zookeeper localhost:2181/kafka --alter --topic topic-config --partitions 3
bin/kafka-topics.sh --zookeeper localhost:2181/kafka --alter --topic topic-config --partitions 3
topic的分区数可不可以减少?如果可以怎么减少?如果不可以,那又是为什么?
不可以减少,被删除的分区数据难以处理。
创建topic时如何选择合适的分区数?
1)创建一个只有1个分区的topic
2)测试这个topic的producer吞吐量和consumer吞吐量。
3)假设他们的值分别是Tp和Tc,单位可以是MB/s。
4)然后假设总的目标吞吐量是Tt,那么分区数=Tt / max(Tp,Tc)
例如:producer吞吐量=5m/s;consumer吞吐量=50m/s,期望吞吐量100m/s;
分区数=100 / 50 =2分区
分区数一般设置为:3-10个
2)测试这个topic的producer吞吐量和consumer吞吐量。
3)假设他们的值分别是Tp和Tc,单位可以是MB/s。
4)然后假设总的目标吞吐量是Tt,那么分区数=Tt / max(Tp,Tc)
例如:producer吞吐量=5m/s;consumer吞吐量=50m/s,期望吞吐量100m/s;
分区数=100 / 50 =2分区
分区数一般设置为:3-10个
Kafka目前有那些内部topic,它们都有什么特征?各自的作用又是什么?
consumer_offsets 以下划线开头,保存消费组的偏移
优先副本是什么?它有什么特殊的作用?
发生leader变化时重选举会优先选择优先副本作为leader(replicas 副本列表中取出第一个broker)
Kafka有哪几处地方有分区分配的概念?简述大致的过程及原理。
不就是消费者负载吗
触发时间:consumer所在服务重启或宕机了 动态给topic增加了分区 消费组订阅了更多的topic
range策略:就是算出平均分配多少,直接一次性分配,多余的平均分配给前面的消费者。比如有10个分区(0-9),3个消费者:分区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策略:就是在rebalance的时候,需要保证如下两个原则。
1)分区的分配要尽可能均匀 。
2)分区的分配尽可能与上次分配的保持相同。
当两者发生冲突时,第一个目标优先于第二个目标 。这样可以最大程度维持原来的分区分配的策略。
比如对于第一种range情况的分配,如果第三个consumer挂了,那么重新用sticky策略分配的结果如下:
consumer1除了原有的0~3,会再分配一个7
consumer2除了原有的4~6,会再分配8和9
触发时间:
range策略:就是算出平均分配多少,直接一次性分配,多余的平均分配给前面的消费者。比如有10个分区(0-9),3个消费者:分区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策略:就是在rebalance的时候,需要保证如下两个原则。
1)分区的分配要尽可能均匀 。
2)分区的分配尽可能与上次分配的保持相同。
当两者发生冲突时,第一个目标优先于第二个目标 。这样可以最大程度维持原来的分区分配的策略。
比如对于第一种range情况的分配,如果第三个consumer挂了,那么重新用sticky策略分配的结果如下:
consumer1除了原有的0~3,会再分配一个7
consumer2除了原有的4~6,会再分配8和9
简述Kafka的日志目录结构。Kafka中有那些索引文件?
每个partition一个文件夹,包含四类文件.index .log .timeindex leader-epoch-checkpoint
.index .log .timeindex 三个文件成对出现 前缀为上一个segment的最后一个消息的偏移, log文件中保存了所有的消息, index文件中保存了稀疏的相对偏移的索引, timeindex保存的则是时间索引
leader-epoch-checkpoint中保存了每一任leader开始写入消息时的offset, 会定时更新,follower被选为leader时会根据这个确定哪些消息可用
.index .log .timeindex 三个文件成对出现 前缀为上一个segment的最后一个消息的偏移, log文件中保存了所有的消息, index文件中保存了稀疏的相对偏移的索引, timeindex保存的则是时间索引
leader-epoch-checkpoint中保存了每一任leader开始写入消息时的offset, 会定时更新,follower被选为leader时会根据这个确定哪些消息可用
如果我指定了一个offset,Kafka怎么查找到对应的消息?
1.通过文件名前缀数字x找到该绝对offset 对应消息所在文件
2.offset-x为在文件中的相对偏移
3.通过index文件中记录的索引找到最近的消息的位置
4.从最近位置开始逐条寻找
如果我指定了一个timestamp,Kafka怎么查找到对应的消息?
原理同上 但是时间的因为消息体中不带有时间戳 所以不精确
kafka过期数据清理
日志清理保存的策略只有delete和compact两种
log.cleanup.policy=delete启用删除策略
log.cleanup.policy=compact启用压缩策略
log.cleanup.policy=delete启用删除策略
log.cleanup.policy=compact启用压缩策略
Kafka中的幂等是怎么实现的
Producer的幂等性指的是当发送同一条消息时,数据在Server端只会被持久化一次,数据不丟不重,但是这里的幂等性是有条件的:
1)只能保证Producer在单个会话内不丟不重,如果Producer出现意外挂掉再重启是无法保证的(幂等性情况下,是无法获取之前的状态信息,因此是无法做到跨会话级别的不丢不重)。
2)幂等性不能跨多个Topic-Partition,只能保证单个Partition内的幂等性,当涉及多个 Topic-Partition时,这中间的状态并没有同步
kafka事务
Kafka从0.11版本开始引入了事务支持。事务可以保证Kafka在Exactly Once语义的基础上,生产和消费可以跨分区和会话,要么全部成功,要么全部失败。
1)Producer事务
为了实现跨分区跨会话的事务,需要引入一个全局唯一的Transaction ID,并将Producer获得的PID和Transaction ID绑定。这样当Producer重启后就可以通过正在进行的Transaction ID获得原来的PID。
为了管理Transaction,Kafka引入了一个新的组件Transaction Coordinator。Producer就是通过和Transaction Coordinator交互获得Transaction ID对应的任务状态。Transaction Coordinator还负责将事务所有写入Kafka的一个内部Topic,这样即使整个服务重启,由于事务状态得到保存,进行中的事务状态可以得到恢复,从而继续进行。
2)Consumer事务
上述事务机制主要是从Producer方面考虑,对于Consumer而言,事务的保证就会相对较弱,尤其时无法保证Commit的信息被精确消费。这是由于Consumer可以通过offset访问任意信息,而且不同的Segment File生命周期不同,同一事务的消息可能会出现重启后被删除的情况。
Kafka中有那些地方需要选举?这些地方的选举策略又有哪些?
- 控制器的选举:Kafka Controller的选举是依赖Zookeeper来实现的,在Kafka集群中哪个broker能够成功创建/controller这个临时(EPHEMERAL)节点他就可以成为Kafka Controller。
- 分区leader的选举
- 消费者相关的选举:组协调器GroupCoordinator需要为消费组内的消费者选举出一个消费组的leader,这个选举的算法也很简单,分两种情况分析。如果消费组内还没有leader,那么第一个加入消费组的消费者即为消费组的leader。如果某一时刻leader消费者由于某些原因退出了消费组,那么会重新选举一个新的leader
Kafka中的延迟队列怎么实现?
kafka实现高吞吐
- 顺序读写磁盘:kafka的消息是不断追加到文件中的,这个特性使kafka可以充分利用磁盘的顺序读写性能顺序读写不需要硬盘磁头的寻道时间,只需很少的扇区旋转时间,所以速度远快于随机读写
- linux中使用sendfile命令,减少一次数据拷贝
- 文件分段:kafka的队列topic被分为了多个区partition,每个partition又分为多个段segment,所以一个队列中的消息实际上是保存在N多个片段文件中通过分段的方式,每次文件操作都是对一个小文件的操作,非常轻便,同时也增加了并行处理能力
- 生产者客户端缓存消息批量发送,消费者批量从broker获取消息,减少网络io次数,充分利用磁盘顺序读写的性能
- 通常情况下kafka的瓶颈不是cpu或者磁盘,而是网络带宽,所以生产者可以对数据进行压缩。
- Kafka还支持对消息集合进行压缩,Producer可以通过GZIP或Snappy格式对消息集合进行压缩压缩的好处就是减少传输的数据量,减轻对网络传输的压力Producer压缩之后,在Consumer需进行解压,虽然增加了CPU的工作,但在对大数据处理上,瓶颈在网络上而不是CPU,所以这个成本很值得
Spring Cloud Alibaba
微服务注册中心Nacos
注册中心+配置中心
Nacos 领域模型划分以及概念详解
数据模型
Nacos 数据模型 Key 由三元组唯一确定, Namespace默认是空串,公共命名空间(public),分组默认是 DEFAULT_GROUP。NameSpace、group可以进行资源隔离,比如我们dev环境下的NameSpace下的服务是调用不到prod的NameSpace下的微服务)
比如我们可以这样划分数据领域:可以划分dev与prod的NameSpace;交易之类的放在一个group中,仓储的放在另一个group中;订单服务跟支付服务集群,分为北京、南京两城市的集群,Nacos如果要做到同城优先调用,也就是北京的订单服务优先调用北京的支付服务,需要我们自己去写负载均衡算法,比如Ribbon的自定义负载
集群配置案例:spring.cloud.nacos.discovery.cluster-name=NJ
集群配置案例:spring.cloud.nacos.discovery.cluster-name=NJ
验证领域模型
不同的NameSpace不能相互调用
不同的group不能相互调用
nacos集群
nacos节点将数据存入mysql中,需要先执行mysql脚本,为了更优雅可以使用nginx作为路由负载
Nacos的配置管理
以往做法
每一个环境一份配置,不能动态更改配置
Nacos配置中心分析
将配置统一放在nacos上
支持动态修改
怎么解决 生产环境,测试环境,开发环境相同的配置(配置通用)
同一个微服务的通用配置
通过查看启动日志看到,我们默认会去找[application-name].yml文件作为通用配置:
比如我们的servlet-context 为order-center,所以我们需要创建一个通用配置文件:order-center.yml配置,那么order-center.yml就是一个通用配置了,不管是启动prod,还是dev都会有该段配置order-server的 context-path 配置
不同微服务的通用配置
通过 shared-dataids 方式实现
spring.cloud.nacos.config.file-extension: yml
#各个微服务共享的配置,注意越拍到后面的公共配置yml优先级越高,日志会打印出来
spring.cloud.nacos.config.shared-dataids: common.yml,common2.yml
#支持动态刷新的配置文件
spring.cloud.nacos.config.refreshable-dataids: common.yml,common2.yml
通过 ext-config方式
spring.cloud.nacos.config.ext-config:
- data-id: common3.yml
group: DEFAULT_GROUP
refresh: true
- data-id: common4.yml
group: DEFAULT_GROUP
refresh: true
- data-id: common3.yml
group: DEFAULT_GROUP
refresh: true
- data-id: common4.yml
group: DEFAULT_GROUP
refresh: true
各个配置的优先级
精准配置 > 不同环境的通用配置 > 不同工程的(ext-config) > 不同工程(shared- dataids)
相关原理
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 Client会维护一个定时心跳来持续通知Nacos Server,说明服务一直处于可用状态,防止被剔除。默认5s发送一次心跳。
服务健康检查:Nacos Server会开启一个定时任务用来检查注册服务实例的健康情况,对于超过15s没有收到客户端心跳的实例会将它的healthy属性置为false(客户端服务发现时不会发现),如果某个实例超过30秒没有收到心跳,直接剔除该实例(被剔除的实例如果恢复发送心跳则会重新注册)
服务发现:服务消费者(Nacos Client)在调用服务提供者的服务时,会发送一个REST请求给Nacos Server,获取上面注册的服务清单,并且缓存在Nacos Client本地,同时会在Nacos Client本地开启一个定时任务定时拉取服务端最新的注册表信息更新到本地缓存
服务同步:Nacos Server集群之间会互相同步服务实例,用来保证服务信息的一致性
Nacos服务注册表结构
Map<namespace, Map<group::serviceName, Service>>
微服务负载均衡组件Ribbon
介绍
Ribbon客户端组件提供一系列的完善的配置,如超时,重试等。通过Load Balancer获取到服务提供的所有机器实例,Ribbon会自动基于某种规则(轮询,随机)去调用这些服务。Ribbon也可以实现我们自己的负载均衡算法(自定义负载算法)
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所在Zone的性能和Server的可用性选择Server,在没有Zone的情况下类是轮询
②: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所在Zone的性能和Server的可用性选择Server,在没有Zone的情况下类是轮询
常见的负载均衡算法
1、随机,通过随机选择服务进行执行,一般这种方式使用较少;
2、轮训,负载均衡默认实现方式,请求来之后排队处理;
3、加权轮训,通过对服务器性能的分型,给高配置,低负载的服务器分配更高的权重,均衡各个服务器的压力;
4、地址Hash,通过客户端请求的地址的HASH值取模映射进行服务器调度。
5、最小链接数;即使请求均衡了,压力不一定会均衡,最小连接数法就是根据服务器的情况,比如请求积压数等参数,将请求分配到当前压力最小的服务器上。
6、其他若干方式。
2、轮训,负载均衡默认实现方式,请求来之后排队处理;
3、加权轮训,通过对服务器性能的分型,给高配置,低负载的服务器分配更高的权重,均衡各个服务器的压力;
4、地址Hash,通过客户端请求的地址的HASH值取模映射进行服务器调度。
5、最小链接数;即使请求均衡了,压力不一定会均衡,最小连接数法就是根据服务器的情况,比如请求积压数等参数,将请求分配到当前压力最小的服务器上。
6、其他若干方式。
负载均衡的实现
通过RestTemplate自定义的负载均衡算法(随机)
可以通过注入DiscoveryClient,去注册中心拿到server地址,然后自定义RestTemplate重写方法,根据自己的负载算法得到ip
经过阅读源码RestTemplate组件得知,不管是post,get请求最终是会调用我们的doExecute()方法,而且在通过调试时可知此时的URL已经是选择完后的具体url,所以我们写一个自定义的MyRestTemplate类继承RestTemplate,然后重新doExucute()方法,重写的时候根据算法改掉URL即可,接着使用我们的自定义的MyRestTemplate进行服务间的调用即可。
通过Ribbon组件来实现负载均衡(默认的负载均衡算法是 轮询)
通过服务消费者配置一个用于负载的restTemplate,在其上加上@LoadBalanced,声明此restTemplate用来负载均衡的
然后使用restTemplate进行服务之间的调用(指明服务名+请求地址即可),需要配置一个@LoadBalanced声明的RestTemplate不然不行,Eureka注册中心可以应该是他有实现负载。使用了全局的负载策略-随机,不设置默认轮训
Ribbon的细粒度自定义配置
基于配置类实现(不推荐)
需求介绍:order-a使用轮训负载,order-b使用随机负载
具体实现代码:针对不同的服务使用不同的负载配置类
基于yml配置实现细粒化(推荐使用)
基于上面场景的配置:
#自定义Ribbon的细粒度配置
order-a:
ribbon:
NFLoadBalancerRuleClassName: com.netflix.loadbalancer.RoundRobinRule
order-b:
ribbon:
NFLoadBalancerRuleClassName: com.netflix.loadbalancer.RandomRule
#自定义Ribbon的细粒度配置
order-a:
ribbon:
NFLoadBalancerRuleClassName: com.netflix.loadbalancer.RoundRobinRule
order-b:
ribbon:
NFLoadBalancerRuleClassName: com.netflix.loadbalancer.RandomRule
解决Ribbon 第一次调用耗时高的配置(饥饿加载)
我们在测试的时候,第一次调用的时候会卡一下,这是因为默认没有开启饥饿加载。Ribbon在进行客户端负载均衡的时候并不是在启动的时候就加载上下文,而是在实际请求时才去创建,因此这个特性使得我们在第一次调用的时候疲软乏力,严重还可能导致超时。所以我们可以通过指定Ribbon具体客户端的名称来开启饥饿加载,即在启动的时候就加载所有配置项的应用程序上下文。
启动饥饿加载:
ribbon:
eager-load:
clients: order-a,order-b
enabled: true
常用参数讲解
每一台服务器重试的次数,不包含首次调用的那一次
ribbon.MaxAutoRetries=1
# 重试的服务器的个数,不包含首次调用的那一台实例
ribbon.MaxAutoRetriesNextServer=2
# 是否对所以的操作进行重试(True 的话 会对post put操作进行重试,存在服务幂等问题)
ribbon.OkToRetryOnAllOperations=true
# 建立连接超时
ribbon.ConnectTimeout=3000
# 读取数据超时
ribbon.ReadTimeout=3000
举列子: 上面最多会进行几次重试
MaxAutoRetries + MaxAutoRetriesNextServer + (MaxAutoRetries * MaxAutoRetriesNextServer)
ribbon.MaxAutoRetries=1
# 重试的服务器的个数,不包含首次调用的那一台实例
ribbon.MaxAutoRetriesNextServer=2
# 是否对所以的操作进行重试(True 的话 会对post put操作进行重试,存在服务幂等问题)
ribbon.OkToRetryOnAllOperations=true
# 建立连接超时
ribbon.ConnectTimeout=3000
# 读取数据超时
ribbon.ReadTimeout=3000
举列子: 上面最多会进行几次重试
MaxAutoRetries + MaxAutoRetriesNextServer + (MaxAutoRetries * MaxAutoRetriesNextServer)
Ribbon自定义负载均衡策略使用
自定义权重负载均衡策略
ribbon没有基于权重的算法,但是nacos中可以设置,所以我们可以获取到nacos设置的权重 然后进行权重算法
自定义同地区机房优先选择负载均衡策略
第一步:获取当前服务所在的集群
第二步:获取一个负载均衡对象
第三步:获取当前调用的微服务的名称
第四步:获取nacos clinet的服务注册发现组件的api
第五步:获取所有的服务实例
第六步:过滤筛选同集群下的所有实例
第七步:选择合适的一个实例调用
第二步:获取一个负载均衡对象
第三步:获取当前调用的微服务的名称
第四步:获取nacos clinet的服务注册发现组件的api
第五步:获取所有的服务实例
第六步:过滤筛选同集群下的所有实例
第七步:选择合适的一个实例调用
解决生产环境金丝雀发布问题
比如 order-center 存在二个版本 V1(老版本) V2(新版本),product-center也存在二个版本V1(老版本) V2新版本 现在需要做到的是order-center(V1)---->product-center(v1),order-center(V2)---->product-center(v2)。记住v2版本是小面积部署的,用来测试用户对新版本功能的。若用户完全接受了v2。我们就可以把V1版本卸载完全部署V2版本。
实现场景说明:同版本能调用,不同版本不能调用
实现方法:同一个集群,同版本号 优先调用策略,可以通过配置一个版本号参数,然后获取比较
实现场景说明:同版本能调用,不同版本不能调用
实现方法:同一个集群,同版本号 优先调用策略,可以通过配置一个版本号参数,然后获取比较
Netflix的RPC组件OpenFeign
什么是feign
Feign是Netflix开发的声明式、模板化的HTTP客户端,其灵感来自Retrofit、JAXRS2.0以及WebSocket。Feign可帮助我们更加便捷、优雅地调用HTTP API。
在Spring Cloud中,使用Feign非常简单——只需创建接口,并在接口上添加注解即可。
Feign支持多种注解,例如Feign自带的注解或者JAXRS注解等。Spring Cloud对Feign进行了增强,使其支持SpringMVC注解,另外还整合了Ribbon和Eureka,从而使得Feign的使用更加方便
Feign日志级别
日志级别
默认情况下,Feign的调用式不打印日志,我们需要通过自定义来打印我们的Feign的日志(basic适用于生产环境)
- NONE,无记录(DEFAULT)
- BASIC,只记录请求方法和 URL 以及响应状态代码和执行时间
- HEADERS,记录基本信息以及请求和响应标头
- FULL,记录请求和响应的头文件,正文和元数据
开启feign日志实现
配置文件加上ConsumerFeignClient 全路径包名:
logging:
level:
lb.study.tulin.feign_demo_consumer.feign: debug
@FeignClient(name = "provider-server",configuration = ProductCenterFeignConfig.class)
@Bean
public Logger.Level level() {
return Logger.Level.FULL;
//return Logger.Level.BASIC;
}
logging:
level:
lb.study.tulin.feign_demo_consumer.feign: debug
@FeignClient(name = "provider-server",configuration = ProductCenterFeignConfig.class)
@Bean
public Logger.Level level() {
return Logger.Level.FULL;
//return Logger.Level.BASIC;
}
yml配置指定,细粒化,通过feign:client:config:微服务名称:loggerLevel: 日志级别来指定 这种优先级比@FeignClient的高
logging:
level:
lb.study.tulin.feign_demo_consumer.feign: debug
feign:
client:
config:
provider-server:
loggerLevel: BASIC
logging:
level:
lb.study.tulin.feign_demo_consumer.feign: debug
feign:
client:
config:
provider-server:
loggerLevel: BASIC
feign中拦截器
在进行认证鉴权的时候,是需要登录的,所以这个时候我们可以根据feign的拦截器去做。
我们只需要实现feign提供的一个接口feign.RequestInterceptor,假设我们在验证权限的时候放在请求头里面的key为oauthToken,先获取当前请求中的key为oauthToken的Token,然后放到feign的请求Header上
我们只需要实现feign提供的一个接口feign.RequestInterceptor,假设我们在验证权限的时候放在请求头里面的key为oauthToken,先获取当前请求中的key为oauthToken的Token,然后放到feign的请求Header上
Feign调用优化方案
开启连接池配置以及使用HTTPClient
feign.httpclient.max‐connections=200 #最大连接数
feign.httpclient.max‐connections‐per‐route: 50 #为每个url请求设置最大连接数
feign.httpclient.enabled: true #让feign底层使用HttpClient去调用
使用OKhttp替换Feign默认的client
Http是膜前比较通用的网络请求方式,用来访问请求交换数据,有效的使用Http可以使应用访问速度变得更快,更节省带宽。OKhttp是一个很棒的Http客户端,具有以下功能和特性:
- 支持SPDY,可以合并多个到同一个主机的请求;
- 使用连接池技术减少请求的延迟(如果SPDY是可用的话);
- 使用GZIP压缩减少传输的数据量;
- 缓存响应避免重复的网络请求
feign的超时设置
(以Feign的超时说了算)
(以Feign的超时说了算)
问题:Feign的底层用的是Ribbon,那么我们怎么配置超时时间服务提供方模拟耗时 睡眠3S
# (Feign不会超时)
ribbon.connectTimeout=2000
ribbon.readTimeout=2000
feign.client.config.default.connectTimeout=5000
feign.client.config.default.readTimeout=5000
# (Feign会超时)
ribbon.connectTimeout=5000
ribbon.readTimeout=5000
feign.client.config.default.connectTimeout=2000
feign.client.config.default.readTimeout=2000
# (Feign不会超时)
ribbon.connectTimeout=2000
ribbon.readTimeout=2000
feign.client.config.default.connectTimeout=5000
feign.client.config.default.readTimeout=5000
# (Feign会超时)
ribbon.connectTimeout=5000
ribbon.readTimeout=5000
feign.client.config.default.connectTimeout=2000
feign.client.config.default.readTimeout=2000
feign第一次请求为什么慢?
懒加载,服务调用的时候才去创建负载均衡的Client,可配置成饥饿加载
微服务限流容错之Sentinel
级联故障问题引发与解决方案
什么是雪崩效应(级联故障)
①:正常情况下,微服务A B C D 都是正常的
②:随着时间推移,在某一个时间点 微服务A突然挂了,此时的微服务B 还在疯狂的调用微服务A,由于A已经挂了,所以B调用A必须等待服务调用超时。而我们知道每次B->A的时候B都会去创建线程(而线程由是计算机的资源 比如cpu 内存等)。由于是高并发场景 B就会阻塞大量的线程。那么B所在的机器就会去创建线程,但是计算机资源是有限的,最后B的服务器就会宕机
③:由于微服务A这个猪队友活生生的把微服务B给拖死,导致微服务B也宕机了,然后也会导致微服务C D 出现类似的情况,最终我们的猪队友A成功的把微服务 B C D 都拖死了。这种情况也叫做服务雪崩。也有一个专业术语( cascading failures ) 级联故障
②:随着时间推移,在某一个时间点 微服务A突然挂了,此时的微服务B 还在疯狂的调用微服务A,由于A已经挂了,所以B调用A必须等待服务调用超时。而我们知道每次B->A的时候B都会去创建线程(而线程由是计算机的资源 比如cpu 内存等)。由于是高并发场景 B就会阻塞大量的线程。那么B所在的机器就会去创建线程,但是计算机资源是有限的,最后B的服务器就会宕机
③:由于微服务A这个猪队友活生生的把微服务B给拖死,导致微服务B也宕机了,然后也会导致微服务C D 出现类似的情况,最终我们的猪队友A成功的把微服务 B C D 都拖死了。这种情况也叫做服务雪崩。也有一个专业术语( cascading failures ) 级联故障
传统解决方案
超时:超时机制不难,也就是配置一下超时时间,例如1秒——每次请求在1秒内必须返回,否则到点就把线程掐死,释放资源!
思路:一旦超时,就释放资源。由于释放资源速度较快,应用就不会那么容易被拖死,自然就不会造成上游服务死亡,当然如果这样的话就需要在每个服务都设置超时机制
思路:一旦超时,就释放资源。由于释放资源速度较快,应用就不会那么容易被拖死,自然就不会造成上游服务死亡,当然如果这样的话就需要在每个服务都设置超时机制
断路器模式
实时监测应用,如果发现在一定时间内失败次数/失败率达到一定阈值,就“跳闸”,断路器打开——此时,请求直接返回,而不去调用原本调用的逻辑。跳闸一段时间后(例如15秒),断路器会进入半开状态,这是一个瞬间态,此时允许一次请求调用该调的逻辑,如果成功,则断路器关闭,应用正常调用;如果调用依然不成功,断路器继续回到打开状态,过段时间再进入半开状态尝试——通过”跳闸“,应用可以保护自己,而且避免浪费资源;而通过半开的设计,可实现应用的“自我修复“。hystrix存在断路器半开,而sentinel没有半开
Sentinel 流量控制,容错,降级
①:引入依赖
②:添加Sentinel后,添加配置,暴露/ actuator/sentinel接口http://localhost:8080/actuator/sentinel,springboot默认是没有暴露的
③:整合Sentinel-dashboard(哨兵流量卫兵)
④:添加整合我们的Sentinel-dashboard(哨兵流量卫兵)配置spring.cloud.sentinel.transport.dashboard=localhost:9999
⑤:此时启动后去dashboard页面发现应用也没有?发现原来需要我们先随便访问服务的一个接口,再打开dashboard页面查看到:
②:添加Sentinel后,添加配置,暴露/ actuator/sentinel接口http://localhost:8080/actuator/sentinel,springboot默认是没有暴露的
③:整合Sentinel-dashboard(哨兵流量卫兵)
④:添加整合我们的Sentinel-dashboard(哨兵流量卫兵)配置spring.cloud.sentinel.transport.dashboard=localhost:9999
⑤:此时启动后去dashboard页面发现应用也没有?发现原来需要我们先随便访问服务的一个接口,再打开dashboard页面查看到:
在sentinel-dashboard上配置规则(流控、降级、容错)即可,规则在开源版本中不支持持久化,只有在付费版本AHAS才有,但是我么可以自己去实现
sentinel整合Ribbon、Feign
sentinel整合feign
FeignClient上添加fallback或者fallbackFactory属性,实现方法,然后再到sentinel-Dashboard上配置规则即可
sentinel整合ribbon
RestTemplate组件上添加@SentinelRestTemplate注解,在 @SentinelRestTemplate 同样的可以指定我们的blockHandlerClass(流控)、fallbackClass(降级) blockHandler、fallback 这四个属性,然后再到sentinel-Dashboard上配置规则即可
ribbon是负载均衡组件,这里可能你会想为什么不直接将规则配置在provider服务上,对!那样也可以,不过是请求到了provider,然后provider返回回来的结果,如果我配置在GET:http://cloud-provider-server/getUserInfoById/1上的话,就不会调用到provider上,而是直接返回结果!减少了网络开销
ribbon是负载均衡组件,这里可能你会想为什么不直接将规则配置在provider服务上,对!那样也可以,不过是请求到了provider,然后provider返回回来的结果,如果我配置在GET:http://cloud-provider-server/getUserInfoById/1上的话,就不会调用到provider上,而是直接返回结果!减少了网络开销
Seata分布式事务解决方案
什么是Seata?
Seata 是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。Seata 将为用户提供了 AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案(AT模式是阿里首推的模式,阿里云上有商用版本的GTS[Global Transaction service 全局事务服务] ) 。下面讲解都是基于阿里的AT协议
角色划分
RM(ResourceManager 资源管理者):理解为我们的一个一个的微服务,也叫做事务的参与者;
TM(TranactionManager 事务管理者):也是我们的一个微服务,但是该微服务是一个带头大哥,充当全局事务的发起者(决定了全局事务的开启,回滚,提交等)。凡是我们的微服务中标注了@GlobalTransactional ,那么该微服务就会被看成是一个TM。我们业务场景中订单微服务就是一个事务管理者,同时也是一个RM
TC(全局事务的协调者):这里就是我们的Seata-server,用来保存全局事务,分支事务,全局锁等记录,然后会通知各个RM进行回滚或者提交
TM(TranactionManager 事务管理者):也是我们的一个微服务,但是该微服务是一个带头大哥,充当全局事务的发起者(决定了全局事务的开启,回滚,提交等)。凡是我们的微服务中标注了@GlobalTransactional ,那么该微服务就会被看成是一个TM。我们业务场景中订单微服务就是一个事务管理者,同时也是一个RM
TC(全局事务的协调者):这里就是我们的Seata-server,用来保存全局事务,分支事务,全局锁等记录,然后会通知各个RM进行回滚或者提交
Seata的整体机制
整体流程机制图
第一阶段
①:解析 SQL:得到 SQL 的类型(UPDATE),表(product),条件(where name = 'TXC';)等相关的信息。
②:查询前镜像:根据解析得到的条件信息,生成查询语句,定位数据。
select id, name, since from product where name = 'TXC';
③:执行业务 SQL:更新这条记录的 name 为 'GTS'。
update product set name = 'GTS' where name = 'TXC';
④:查询后镜像:根据前镜像的结果,通过 主键 定位数据(通过id获取,因为name都变了)
select id, name, since from product where id = 1`;
⑤:插入回滚日志:把前后镜像数据以及业务 SQL 相关的信息组成一条回滚日志记录,插入到 UNDO_LOG 表中。
⑥:提交前,向 TC 注册分支:申请 product 表中,主键值等于 1 的记录的 全局锁 。
⑦:本地事务提交:业务数据的更新和前面步骤中生成的 UNDO LOG 一并提交。
⑧:将本地事务提交的结果上报给 TC。
②:查询前镜像:根据解析得到的条件信息,生成查询语句,定位数据。
select id, name, since from product where name = 'TXC';
③:执行业务 SQL:更新这条记录的 name 为 'GTS'。
update product set name = 'GTS' where name = 'TXC';
④:查询后镜像:根据前镜像的结果,通过 主键 定位数据(通过id获取,因为name都变了)
select id, name, since from product where id = 1`;
⑤:插入回滚日志:把前后镜像数据以及业务 SQL 相关的信息组成一条回滚日志记录,插入到 UNDO_LOG 表中。
⑥:提交前,向 TC 注册分支:申请 product 表中,主键值等于 1 的记录的 全局锁 。
⑦:本地事务提交:业务数据的更新和前面步骤中生成的 UNDO LOG 一并提交。
⑧:将本地事务提交的结果上报给 TC。
二阶段-提交
①:收到 TC 的分支提交请求,把请求放入一个异步任务的队列中,马上返回提交成功的结果给 TC。
②:异步任务将异步批量地删除相应 UNDO LOG 记录。
②:异步任务将异步批量地删除相应 UNDO LOG 记录。
二阶段-回滚
收到 TC 的分支回滚请求,开启一个本地事务,执行如下操作
①:通过 XID 和 Branch ID 查找到相应的 UNDO LOG 记录;
②:数据校验:拿 UNDO LOG 中的后镜与当前数据进行比较,如果有不同,说明数据被当前全局事务之外的动作做了修改;
③:根据 UNDO LOG 中的前镜像和业务 SQL 的相关信息生成并执行回滚的语句;
④:提交本地事务。并把本地事务的执行结果(即分支事务回滚的结果)上报给 TC
网关服务GateWay
什么是SpringCloud gateWay
Spring Cloud Gateway是Spring Cloud官方推出的第二代网关框架,取代Netflix的Zuul网关。网关作为流量的,在微服务系统中有着非常作用。据说性能是第一代网关zuul的1.5倍。(基于Netty,WebFlux /flʌks/);
注意点:由于不是Sevlet容器,所以他不能打成war包, 只支持SpringBoot2.X不支持1.x
注意点:由于不是Sevlet容器,所以他不能打成war包, 只支持SpringBoot2.X不支持1.x
网关作用
网关常见的功能有路由转发、权限校验、限流控制等作用
GateWay的核心概念与使用
基本核心概念
- 路由网关的基本构建模块,它是由ID、目标URl、断言集合和过滤器集合定义,如果集合断言为真,则匹配路由。
- Predicate(断言):这是java 8的一个函数式接口predicate,可以用于lambda表达式和方法引用,输入类型是:Spring Framework ServerWebExchange,允许开发人员匹配来自HTTP请求的任何内容,例如请求头headers和参数paramers
- Filter(过滤器):这些是使用特定工厂构建的Spring Framework GatewayFilter实例,这里可以在发送下游请求之前或之后修改请求和响应
路由断言(Predicate)
-Path配置路由
①:转发到响应的地址
spring:
cloud:
gateway:
routes:
- id: accurate #id必须要唯一
uri: http://spring.io
predicates:
#访问http://localhost:8080//projects/spring-framework任意请求都会转发到 http://spring.io/projects/spring-framework请求
- Path=/projects/spring-framework
②:转发到相应服务中去
spring:
cloud:
gateway:
routes:
- id: mic-accurate #id必须要唯一
uri: lb://product-center
#表示:访问http://localhost:8080/selectProductInfoById/1
#会转发到http://product-center/selectProductInfoById/1
#而http://localhost:8080/selectProductInfoById/2 不会被转发
predicates:
- Path=/selectProductInfoById/1
spring:
cloud:
gateway:
routes:
- id: order-server #id必须要唯一
uri: lb://order-server #lb表示使用负载方式
predicates:
#http://localhost:8080/order/getOrderById?id=1--->http://localhost:8080/order-server/order/getOrderById?id=1
#会在你请求的连接前面加上order-server,并不是像zuul那样替换,感觉这个真的不灵活,需要下游服务规定好才行,不然就是一个服务写好多规则。。。。
- Path=/order/**
After路由断言工厂
当前请求时间>配置时间,就会路由,因为没有配置-Path,所以是针对所有请求,时间可通过System.out.println(ZonedDateTime.now())
predicates:
- After=2010-12-16T15:53:22.999+08:00[Asia/Shanghai]
predicates:
- After=2010-12-16T15:53:22.999+08:00[Asia/Shanghai]
Before路由断言工厂
当前请求时间<配置时间,before就是在配置时间之前就能路由
predicates:
- Before=2030-12-16T15:53:22.999+08:00[Asia/Shanghai]
predicates:
- Before=2030-12-16T15:53:22.999+08:00[Asia/Shanghai]
Between路由断言工厂
当前请求时间在配置的时间区间内
Cookie路由断言工厂
Cookie路由断言工厂会取两个参数--cookie名称对应的key和value,当请求中携带的与断言中的一致,则路由匹配成功
#当我们的请求中包含了Cookie name=Company value=libo才转发请求
- Cookie=Company,libo
#当我们的请求中包含了Cookie name=Company value=libo才转发请求
- Cookie=Company,libo
Header路由断言工厂
带入header的k=X-Request-appId v=libo才会被路由
- Header=X-Request-appId,libo
- Header=X-Request-appId,libo
Host路由断言工厂
Host路由断言工厂根据配置的Host,对请求中的Host进行断言处理,断言成功则进行转发
#说明请求http://localhost:8080/selectProductInfoById/1的Host必须满足www.libo.com:8888或者localhost:8080才会转发到http://product-center/selectProductInfoById/1
- Host=www.libo.com:8888,localhost:8080
- Host=www.libo.com:8888,localhost:8080
RemoteAddr路由断言工厂
RemoteAddress路由断言工厂配置一个IPv4或IPv6网段的字符串或者IP。当请求的IP地址在网段之内或者和配置的IP相同,则匹配成功。
# 当且仅当请求IP是192.168.199.1/32网段,例如192.168.199.10,才会转发到用户微服务
- RemoteAddr=192.168.199.1/32
- RemoteAddr=192.168.199.1/32
Method路由断言工厂
Method路由断言会根据请求方式是post还是get等进行断言匹配
#当前请求的方式 http://localhost:8080/selectProductInfoById/1 是Post才会被转发到http://order-server/selectProductInfoById/1
- Method=Post
- Method=Post
Query路由断言工厂
Query路由断言工厂会从请求中获取两个参数,将请求中参数和Query断言路由中的配置进行匹配,比如http://localhost:8080/order/getOrderById?id=1&company=0 中的company=0 会和我以下配置的匹配
- Query=company,0
- Query=company,0
自定义谓词工厂(路由断言工厂)
gateway内置Filter
SpringCloudGateway 内置了很多的过滤器工厂,当然可以自己根据场景自定义Filter。路由过滤器允许以某种方式修改请求进来的http请求或返回的http响应。路由过滤器主要作用于需要处理的特定路由。gateway提供了很多种类的过滤器工厂,过滤器的实现类将近二十多个。总的来说,可以分为七类:Header、Parameter、Path、Status、Redirect跳转、Hystrix熔断和RateLimiter。
传统认证与微服务认证Oauth2
单体应用的安全性
安全验证解决方案
方案:系统中某些页面只有在正常登录后才可以使用,用户请求这些页面时要检查session中有无该用户信息,我们可以编写一个用于检测用户是否登录的过滤器,如果用户未登录,则重定向到指定的登录页面
集群环境下如何解决登陆问题
问题引发:当用户第一次的时候到了节点一上,然后登陆可以访问,但是当继续访问Ng又给负载到了节点二上,又要重新登陆,这样显然不行
解决方案一:NG的iphash算法
IP绑定 ip_hash。每个请求按访问ip的hash结果分配,这样每个访客固定访问一个后端服务器,可以解决session的问题
缺点:不能充分考虑到各个服务器的性能,不能均衡负载出去,有可能同一时刻的访问通过ip_hash出来都请求到一台服务器上,而且这台服务器的性能不行???
解决方案二:集中式Session
这种方案关键就是,将token保存在cookie中,用户每次请求都带上这个cookie,然后服务器判断这个cookie中的token是否有效即可。上面那种是把session保存在了服务器中
微服务安全Oauth2
什么是Oauth2协议
OAuth(开放授权)是一个开放标准,允许用户(你)授权第三方应用(王者农药)访问用户存储在另外的服务提供者(QQ服务器)上的信息,而不需要将用户名和密码提供给第三方移动应用(王者农药)这个就是典型的授权码模式
Oauth2的四种授权模式
密码模式:适用的业务场景 客户端应用(手机app) 是高度受信用的,一般是自己公司开发的app项目。
授权码模式(最安全的模式) 业务场景 第三方不授信的,搭建自己的开发能力平台。
简化模式(开发中几乎接触不到 适用于 客户端就是一堆js css html 没有前端服务器)
客户端模式(开发中几乎用不到,这种模式 用户都没有参与过程)
单点登录
单点登录就是在多个系统中,用户只需一次登录,各个系统即可感知该用户已经登录
HTTP协议是无状态的,Session不能依据HTTP连接来判断是否为同一个用户。于是乎:服务器向用户浏览器发送了一个名为JESSIONID的Cookie,它的值是Session的id值。其实Session是依据Cookie来识别是否是同一个用户。
所以,一般我们单系统实现登录会这样做:
所以,一般我们单系统实现登录会这样做:
- 登录:将用户信息保存在Session对象中
- 如果在Session对象中能查到,说明已经登录
- 如果在Session对象中查不到,说明没登录(或者已经退出了登录)
- 注销(退出登录):从Session中删除用户的信息
- 记住我(关闭掉浏览器后,重新打开浏览器还能保持登录状态):配合Cookie来用
oauth2中使用单点登录
问题分析
前面我们创建的项目,我们要获取token只能主动去调用oauth2提供给我们接口,此时如果我们前端直接这么来,那应用id跟应用密码需要存储在前端,这样不安全,我们应该提供一个单独的登录接口,比如密码模式登录接口,比如授权码模式登录接口。前端只需要提供账号密码即可,又或者跳转到认证服务器登录接口上去,接着我们的登录接口帮忙去带上应用的等id信息获取
spring
Spring常见底层核心注解
Spring框架功能整体介绍
Spring体系架构(基于4.x)
Spring Core Container
模块作用: Core 和 Beans 模块是框架的基础部分,提供 IOC(控制反转)和依赖注入(DI)特性。 这里的基础 概念是 BeanFactory,它提供对 Factory 模式的经典实现来消除对程序’性单例模式的需要,并真 正地允许你从程序逻辑中分离出依赖关系和配置
模块作用: Core 和 Beans 模块是框架的基础部分,提供 IOC(控制反转)和依赖注入(DI)特性。 这里的基础 概念是 BeanFactory,它提供对 Factory 模式的经典实现来消除对程序’性单例模式的需要,并真 正地允许你从程序逻辑中分离出依赖关系和配置
Core
主要包含 Spring 框架基本的核心工具类, Spring 的其他组件都要用到这个包 里的类, Core模块是其他组件的基 本核心
主要包含 Spring 框架基本的核心工具类, Spring 的其他组件都要用到这个包 里的类, Core模块是其他组件的基 本核心
Beans (BeanFacotry的作用)
它包含访问配直文件、创建和管理 bean 以及进行 Inversion of Control I Dependency Injection ( IoC/DI )操作相关的所有类
它包含访问配直文件、创建和管理 bean 以及进行 Inversion of Control I Dependency Injection ( IoC/DI )操作相关的所有类
Context(处理BeanFactory,以下是ApplicationContext的作用)
模块构建于 Core 和 Beans 模块基础之上,提供了一种类似JNDI 注册器的框架式的对象访问方法。 Context 模块继承了 Beans 的特性,为 Spring 核心提供了大量扩展,添加了对国际化(例如资源绑定)、事件传播、资源加载和对 Context 的透明创 建的支持。 Context 模块同时也支持 J2EE 的一些特性,ApplicationContext 接口是 Context 模块的关键
本质区别:(使用BeanFacotry的bean是延时加载的,ApplicationContext是非延时加载的)
模块构建于 Core 和 Beans 模块基础之上,提供了一种类似JNDI 注册器的框架式的对象访问方法。 Context 模块继承了 Beans 的特性,为 Spring 核心提供了大量扩展,添加了对国际化(例如资源绑定)、事件传播、资源加载和对 Context 的透明创 建的支持。 Context 模块同时也支持 J2EE 的一些特性,ApplicationContext 接口是 Context 模块的关键
本质区别:(使用BeanFacotry的bean是延时加载的,ApplicationContext是非延时加载的)
Expression Language
模块提供了强大的表达式语言,用于在运行时查询和操纵对象。 它是 JSP 2.1 规范中定义的 unifed expression language 的扩展。 该语言支持设直/获取属 性的值,属性的分配,方法的调用,访问数组上下文( accessiong the context of arrays )、 容器和索引器、逻辑和算术运算符、命名变量以及从Spring的 IoC 容器中根据名称检 索对象。 它也支持 list 投影、选择和一般的 list 聚合
模块提供了强大的表达式语言,用于在运行时查询和操纵对象。 它是 JSP 2.1 规范中定义的 unifed expression language 的扩展。 该语言支持设直/获取属 性的值,属性的分配,方法的调用,访问数组上下文( accessiong the context of arrays )、 容器和索引器、逻辑和算术运算符、命名变量以及从Spring的 IoC 容器中根据名称检 索对象。 它也支持 list 投影、选择和一般的 list 聚合
Spring Data Access/Integration
JDBC
模块提供了一个 JDBC 抽象层,它可以消除冗长的 JDBC 编码和解析数据库厂 商特有的错误代码。这个模块包含了 Spring 对 JDBC 数据访问进行封装的所有类
ORM 模块为流行的对象-关系映射 API,
如 JPA、 JDO、 Hibernate、 iBatis 等,提供了 一个交互层。 利用 ORM 封装包,可以混合使用所有 Spring 提供的特性进行 O/R 映射, 如前边提到的简单声 明性事务管理
如 JPA、 JDO、 Hibernate、 iBatis 等,提供了 一个交互层。 利用 ORM 封装包,可以混合使用所有 Spring 提供的特性进行 O/R 映射, 如前边提到的简单声 明性事务管理
OXM 模块提供了一个对 ObjecνXML 映射实现的抽象层
Object/XML 映射实现包括 JAXB、 Castor、 XMLBeans、 JiBX 和 XStrearn
Object/XML 映射实现包括 JAXB、 Castor、 XMLBeans、 JiBX 和 XStrearn
JMS ( Java Messaging Service )
模块主要包含了 一些制造和消 费消息的特性
模块主要包含了 一些制造和消 费消息的特性
Transaction
支持编程和声明性的事务管理,这些事务类必须实现特定的接口,并 且对所有的 POJO 都适用
Spring Web
Web 模块:提供了基础的面向 Web 的集成特性c 例如,多文件上传、使用 servlet listeners 初始化IoC 容器以及一个面向 Web 的应用上下文。 它还包含 Spring 远程支持中 Web 的相关部分。
Spring Aop
Aspects 模块提供了对 AspectJ 的集成支持。
Instrumentation 模块提供了 class instrumentation 支持和 classloader 实现,使得可以在特定的应用服务器上使用
Aspects 模块提供了对 AspectJ 的集成支持。
Instrumentation 模块提供了 class instrumentation 支持和 classloader 实现,使得可以在特定的应用服务器上使用
Test
Test 模块支持使用 JUnit 和 TestNG 对 Spring 组件进行测试
控制反转和依赖注入
什么是控制反转?
IoC(Inverse of Control:控制反转)是一种设计思想,就是 将原本在程序中手动创建对象的控制权,交由Spring框架来管理。 IoC 容器实际上就是个Map(key,value),Map 中存放的是各种对象。控制反转的理解分为控制跟反转(那控制什么?反转什么?),结合上面案例说就是,本来我们的底盘是依赖轮胎的(bean对象都是我们自己控制并管理创建),反转过来就是把控制权给出去,也就变成了轮胎去依赖底盘(由spring去控制并管理创建bean对象),这样你就算再怎么去改轮胎,我的轮胎是依赖于底盘的,底盘没动,我改的轮胎就必须按照依赖来改动
控制反转给我们带来的好处,直白的说,就是我们程序员的业务代码和我们组件创建的代码彻底的分离开来进行解耦, 我们程序员只要专注业务代码,不需要关心我们所需的组件是如何创建来的。真正做到“使用 即有” 不需自己创建
控制反转给我们带来的好处,直白的说,就是我们程序员的业务代码和我们组件创建的代码彻底的分离开来进行解耦, 我们程序员只要专注业务代码,不需要关心我们所需的组件是如何创建来的。真正做到“使用 即有” 不需自己创建
ioc的思想最核心的地方在于,资源不由使用资源的双方管理,而由不使用资源的第三方管理,这可以带来很多好处。
第一,资源集中管理,实现资源的可配置和易管理。
第二,降低了使用资源双方的依赖程度,也就是我们说的耦合度
依赖注入
就是注入对象属性呗,我们使用的时候找spring拿即可
Spring IOC 容器底层注解使用
xml配置文件的形式 、 配置类的形式
在配置类上写@CompentScan注解来进行包扫描
配置Bean的作用域对象
①:在不指定@Scope的情况下,所有的bean都是单实例的bean,而且是饿汉加载(容器启动实例就创建好了)
②:指定@Scope为 prototype 表示为多实例的,而且还是懒汉模式加载(IOC容器启动的时候,并不会创建对象,而是在第一次使用的时候才会创建)
③:@Scope指定的作用域方法取值
b) prototype 多实例的
c) request 同一次请求
d) session 同一个会话级别
Bean的懒加载@Lazy(主要针对单实例的bean 容器启动的时候,不创建对象,在第一次使用的时候才会创建该对象)
@Conditional进行条件判断等.
往IOC 容器中添加组件的方式
①:通过@CompentScan +@Controller @Service @Repository/rɪˈpɑːzətɔːri/ @compent。适用场景: 针对我们自己写的组件可以通过该方式来进行加载到容器中。
②:通过@Bean的方式来导入组件(适用于导入第三方组件的类)
②:通过@Bean的方式来导入组件(适用于导入第三方组件的类)
③:通过@Import来导入组件 (导入组件的id为全类名路径)
@Import(value = {Person.class, Car.class})
通过@Import 的ImportSeletor类实现组件的导入 (导入组件的id为全类名路径)
通过@Import的 ImportBeanDefinitionRegister导入组件 (可以指定bean的名称)
④:通过实现FacotryBean接口来实现注册组件
Bean的初始化方法和销毁方法.
①:什么是bean的生命周期?
bean的创建----->初始化----->销毁方法
//指定了bean的生命周期的初始化方法和销毁方法.
@Bean(initMethod = "init",destroyMethod = "destroy")
@Bean(initMethod = "init",destroyMethod = "destroy")
针对单实例bean的话,容器启动的时候,bean的对象就创建了,而且容器销毁的时候,也会调用Bean的销毁方法
针对多实例bean的话,容器启动的时候,bean是不会被创建的而是在获取bean的时候被创建,而且bean的销毁不受IOC容器的管理
②:通过 InitializingBean和DisposableBean 的二个接口实现bean的初始化以及销毁方法
③:通过JSR250规范 提供的注解@PostConstruct 和@ProDestory标注的方法
④:通过Spring的BeanPostProcessor的 bean的后置处理器会拦截所有bean创建过程
postProcessBeforeInitialization 在init方法之前调用、postProcessAfterInitialization 在init方法之后调用
通过@Value + @PropertySource来给组件赋值
自动装配
@AutoWired的使用
自动装配首先时按照类型进行装配,若在IOC容器中发现了多个相同类型的组件,那么就按照 属性名称来进行装配
假设我们需要指定特定的组件来进行装配,我们可以通过使用@Qualifier("tulingDao")来指定装配的组件或者在配置类上的@Bean加上@Primary注解
使用autowired 可以标注在方法上
@Resource(JSR250规范)
功能和@AutoWired的功能差不多一样,但是不支持@Primary 和@Qualifier的支持
@InJect(JSR330规范)
需要导入jar包依赖,功能和支持@Primary功能 ,但是没有Require=false的功能
通过@Profile注解 来根据环境来激活标识不同的Bean
- @Profile标识在类上,那么只有当前环境匹配,整个配置类才会生效
- @Profile标识在Bean上 ,那么只有当前环境的Bean才会被激活
- 没有标志为@Profile的bean 不管在什么环境都可以被激活
激活切换环境的方法
- 方法一:通过运行时jvm参数来切换 -Dspring.profiles.active=test|dev|prod
- 方法二:通过代码的方式来激活ctx.getEnvironment().setActiveProfiles("test","dev");
结合生活案例看Spring-IOC容器加载流程
- 比如我们要去汽车店定制一台汽车
Spring-IOC加载流程详细分析
①:首先ioc会帮我们创建很多的bean,所以我们的ioc容器中肯定是有很多的bean的。就比如bean就是一台汽车
②:在我们代码中,我们一开始会通过xml或者配置类加载一个context,然后就能从context中去拿到我们的bean
③:当然bean总要有东西去创建吧,就像汽车一样,需要汽车工厂(BeanFactory )去生产,spring通过 BeanFactory 创建bean
④:我们要定制一台汽车,那么我们需要告诉设计师(BeanDefinitionRegistry),我们的汽车需要做成什么样,比如颜色,款式啊(比如前面的@Service修饰的Car.class)。然后设计师就设计成一张图纸记录我们的要求(BeanDefinition),在BeanDefinition中有很多的属性,比如是否单例啊、是否懒加载啊、需要依赖注入的类啊、很多很多
⑤:同时我们得需要先通过BeanDefinitionReader读取我们的配置类或者xml,结合生活就是比如销售到地铁口(配置类、xml)去找人买车;此时销售找了100个人,但是可能只有10个人需要买车(扫描类,扫描出需要生产bean的类),然后设计师再根据这些人的要求画好图纸交给BeanFactory生产汽车
⑥:接下来分析bean的创建过程(生命周期),在BeanFactory.getBean()后我们的Bean首先会创建一个空壳对象(属性都为空的【如果是@Autowire方式,则通过反射-bean定义中有一个对象的class路径,如果是@Bean这种就是工厂方法,由我们自己new的,ioc直接拿】),然后填充属性(@Autowire修饰的属性),接着再调用初始化方法(我们自己写的比如@PostConstruct、@PreDestroy修饰的方法),最后再将创建好的bean放入到一级缓存中(单例池-Map<beanName,bean-ref>)
扩展点:虽然到了这里了,已经完成了ioc容器的加载过程,但是中间其实还是存在很多的细节的,比如一系列的扩展点,spring中很多核心功能、第三方框架整合spring都是通过这一系列的扩展点完成的
⑦:前面讲了汽车设计师会根据我们的要求设计好图纸交给工厂生产,那我们就不能再图纸设计完后修改吗?比如我觉得设计师设计的这个外观需要改改。BeanFactoryPostProcessor/ˈprəːsesər/(bean工厂后置处理器)修改我们的图纸,BeanDefinitionRegistryPostProcessor bean定义注册器后置处理器可以添加图纸
⑦:前面讲了汽车设计师会根据我们的要求设计好图纸交给工厂生产,那我们就不能再图纸设计完后修改吗?比如我觉得设计师设计的这个外观需要改改。BeanFactoryPostProcessor/ˈprəːsesər/(bean工厂后置处理器)修改我们的图纸,BeanDefinitionRegistryPostProcessor bean定义注册器后置处理器可以添加图纸
⑧:同样的Bean的生命周期中,同样存在很多的后置处理器,在实例化前后、填充属性前后、初始化前后都会调用很多的后置处理器。像AOP就是通过初始化后-后置处理器实现
⑨:而且在bean生命周期初始化阶段会回调很多的Aware,这些方法也是扩展点
涉及到的几个问题(结合为何这么做讲,比如初始化i o c他需要哪几步做哪些事
描述下BeanFactory
BeanFactory是spring的顶层核心接口,用作于bean生命周期的管理,使用了简单工厂模式,负责生产bean
BeanFactory和ApplicationContext的区别
beanfactory顾名思义,它的核心概念就是bean工厂,用作于bean生命周期的管理,而applicationcontext这个概念就比较丰富了,单看名字(应用上下文)就能看出它包含的范围更广,它继承自bean factory但不仅仅是继承自这一个接口,还有继承了其他的接口,所以它不仅仅有bean factory相关概念,更是一个应用系统的上下文,其设计初衷应该是一个包罗万象的对外暴露的一个综合的API。applicationcontext有很多的扩展接口,通过源码看的话它有创建好几个开创者对象(比如后置处理器)(一开始出来的对象保证后面的一系列工作),比如AnnotationConfigApplicationContext加载配置文件,触发类路径扫描,以编程方式注册bean定义和带注解的类,以及(从5.0开始)注册功能bean定义
简述SpringIoC的加载过程
首先会通过BeanDefinitionReader读取我们的配置(xml、配置类、注解配置),然后通过BeanDefinitionScanner扫描出哪些类需要创建bean,知道了哪些类需要创建bean后,就可以包装成BeanDefinition定义了,接着BeanDefinitionRegistry就会把这些BeanDefinition放入一个beanDefinitionMap中去供BeanFactory使用,此外还有两个后置处理器,Bean工厂后置处理器提供我们在getBean前修改bean定义的能力,以及提供bean定义注册器后置处理器可以去添加图纸(mybatis的Mapper就是通过这个添加的动态代理)。BeanFactory会通过这些个BeanDefinition开始创建我们的bean。创建bean的时候也会调用很多的后置处理器(9次调用),还有在初始化的时候会回调很多的Aware(Aware的作用就是当我们需要用到spring容器中的属性时就可以通过实现Aware接口来获取)
简述Bean的生命周期
在创建bean的时候分为实例化、填充属性、初始化(会调回调很多的Aware,Aware的作用就是当我们需要用到spring容器中的属性时就可以通过实现Aware接口来获取),最后就是放入到一级缓存中,也是个Map,当然在这个bean创建时也会调用很多的后置处理器:总共有9次调用,在每个阶段的前后都有调用,比如BeanPostProcessor.postProcessAfterInitialization(),这就也是我们AOP创建代理的地方
Spring中有哪些扩展接口及调用时机
首先把这些扩展接口按照getBean前跟getBean中划分。
getBean前:主要有BeanFactoryPostProcessor(Bean工厂后置处理器)、BeanDefinitionRegistryPostProcessor(bean定义注册器后置处理器 )Bean工厂后置处理器提供我们在getBean前修改bean定义的能力,bean定义注册器后置处理器可以去添加图纸(mybatis的Mapper就是通过这个添加的动态代理)。spring很多地方都是依赖这些扩展接口做的,比如在applicationContext创建好几个开创者的实例(加载配置实例、扫描实例、bean定义注册器等),而且在一开始会存在一个ConfigurationClassPostProcessor(继承Bean工厂后置处理器也继承bean定义注册器后置处理器)会解析加了@Configuration的配置类,还会解析@ComponentScan、@ComponentScans注解扫描的包,以及解析@Import等注解。
getBean中:主要是在bean的生命周期中的后置处理器跟Aware(感知到...)。后置处理器在bean的各个阶段前中后基本都有调用,总共调用9次,比如实例化前InstantiationAwareBeanPostProcessor,在这里我们可以直接返回bean,而不走后面的阶段了。这些后置处理器可以帮我们做很多事情,而且spring中也是很多地方用到了:解决循环引用AOP(实例化后、赋值前)、初始化后AOP:创建代理(初始时)等等。除了后置处理器还有回调很多的Aware,这些Aware的作用就是当我们需要用到spring容器中的属性时就可以通过实现Aware接口来获取,比如我们要拿到beanName可以实现BeanNameAware,想拿到ApplicationContext上下文可以实现ApplicationContextAware等等。
getBean前:主要有BeanFactoryPostProcessor(Bean工厂后置处理器)、BeanDefinitionRegistryPostProcessor(bean定义注册器后置处理器 )Bean工厂后置处理器提供我们在getBean前修改bean定义的能力,bean定义注册器后置处理器可以去添加图纸(mybatis的Mapper就是通过这个添加的动态代理)。spring很多地方都是依赖这些扩展接口做的,比如在applicationContext创建好几个开创者的实例(加载配置实例、扫描实例、bean定义注册器等),而且在一开始会存在一个ConfigurationClassPostProcessor(继承Bean工厂后置处理器也继承bean定义注册器后置处理器)会解析加了@Configuration的配置类,还会解析@ComponentScan、@ComponentScans注解扫描的包,以及解析@Import等注解。
getBean中:主要是在bean的生命周期中的后置处理器跟Aware(感知到...)。后置处理器在bean的各个阶段前中后基本都有调用,总共调用9次,比如实例化前InstantiationAwareBeanPostProcessor,在这里我们可以直接返回bean,而不走后面的阶段了。这些后置处理器可以帮我们做很多事情,而且spring中也是很多地方用到了:解决循环引用AOP(实例化后、赋值前)、初始化后AOP:创建代理(初始时)等等。除了后置处理器还有回调很多的Aware,这些Aware的作用就是当我们需要用到spring容器中的属性时就可以通过实现Aware接口来获取,比如我们要拿到beanName可以实现BeanNameAware,想拿到ApplicationContext上下文可以实现ApplicationContextAware等等。
Ioc容器加载过程源码
配置类的方式就是通过后置处理器进行扫描以及创建bean定义,如果是xml形式的是耦合在一起的,耦合在context构造函数中
BeanFactory和FactoryBean的区别
虽然这两个名字很像,但是他们根本不是一个东西。BeanFactory是bean的工厂,是spring的顶级接口,没有BeanFactory就没有bean的存在,重要程度可想而知。
FactoryBean也是一个接口,被它修饰的bean将成为一个特殊的bean,原本的bean将会隐藏,而是由FactoryBean的getObject() 返回最终的bean,这种bean在spring的getBean中是不会去创建bean的,而是直接拿我们创建好的bean
FactoryBean也是一个接口,被它修饰的bean将成为一个特殊的bean,原本的bean将会隐藏,而是由FactoryBean的getObject() 返回最终的bean,这种bean在spring的getBean中是不会去创建bean的,而是直接拿我们创建好的bean
请介绍BeanFactoryPostProcessor在Spring中的用途
比如在ConfigurationClassPostProcessor(继承Bean工厂后置处理器也继承bean定义注册器后置处理器)spring中就是通过bean工厂后置处理器解析加了@Configuration的配置类,还会解析@ComponentScan /kəmˈpoʊnənt/、@ComponentScans注解扫描的包,以及解析@Import等注解
bean工厂后置处理器源码分析
AnnotatedBeanDefinitionReader帮我们创建了很多核心东西,有后置处理器、事件等等,最重要的是ConfigurationClassPostProcessor(bean工厂后置处理器、bean定义注册后置处理器),spring配置类方式加载配置类就是通过这个后置处理器处理的,里面逻辑很复杂
主要看ConfigurationClassPostProcessor是一个bean工厂后置处理器跟bean定义注册器后置处理器,以及实现了Ordered、跟PriorityOrdered两个接口(在执行的时候很关键)
如果配置类中加了@Configuration,会创建动态代理,也就是每次会从ioc容器中拿Tank,而不是new
如果配置类中没有加@Configuration,每次都会调用createTank()【@Bean修饰】,也就每次都会new一个Tank对象
如果配置类中没有加@Configuration,每次都会调用createTank()【@Bean修饰】,也就每次都会new一个Tank对象
看几个问题
加与不加@Configuration的区别
如果配置类中加了@Configuration,会创建动态代理,也就是每次会从ioc容器中拿Tank,而不是new
如果配置类中没有加@Configuration,每次都会调用createTank(),也就每次都会new一个Tank对象
如果配置类中没有加@Configuration,每次都会调用createTank(),也就每次都会new一个Tank对象
重复beanName的覆盖原则
如果是两个都是用的@Component的话,会报异常,如果是一个@Component一个@Bean的话,@Bean的会覆盖@Component的(@Bean是后面解析的,@Component是通过@ComponentScan注解扫描第一个解析的)
bean工厂后置处理器执行流程
- BeanDefinitionRegistryPostProcessor执行:context手动添加的》》PriorityOrdered级别》》Ordered级别》》没有实现任何优先级接口
- BeanFactoryPostProcessor执行:同时实现了BeanDefinitionRegistryPostProcessor的》》PriorityOrdered级别》》Ordered级别》》没有实现任何优先级接口
- BeanDefinitionRegistryPostProcessor的执行是每次执行完都会清理掉已经执行过的,然后再重新从Map<BeanDefinition>中获取,这样保证了当我们开元者ConfigurationClassPostProcessor在进行扫描后,后面可以执行到我们写的BeanDefinitionRegistryPostProcessor
- BeanFactoryPostProcessor的执行而是只从Map<BeanDefinition>拿取一次,然后根据执行优先级放到不同List中,在依次循环执行
Spring 是如何解决循环依赖的
模拟spring循环依赖以及慢慢解决
什么是循环依赖
所谓的循环依赖是指,A 依赖 B,B 又依赖 A,它们之间形成了循环依赖。或者是 A 依赖 B,B 依赖 C,C 又依赖 A。
初始存在循环依赖问题代码
此时是存在循环依赖问题的,我在给A设置B属性时会去调用getBean(B),然后B那里又会去getBean(A),所以造成一个死循环调用
改良版本--使用一级缓存优化
在getBean的时候判断一级缓存中是否存在,如果存在则直接返回。把添加bean到一级缓存从原来初始化后添加改为实例化添加
可以看到解是解决了循环依赖的问题,但是其实还是存在问题的
存在的问题
我们将put到一级缓存的代码放到了初始化前,如果在高并发的情况下,可能存在线程拿到一级缓存的脏数据(未进行属性赋值的对象),这样显然是不行的
注意:spring本身启动时单线程不存在并发的,但是如果设置了懒加载的话,bean不会在容器启动时创建,此时就可能存在并发问题
改良版本--使用二级缓存优化
在实例化阶段把bean放到二级缓存中,初始化后放入一级缓存中,在getBean的时候先从一级缓存中拿,如果一级缓存不存在再从二级缓存拿。尽管二级缓存放入的是纯净bean,依旧是不影响的,比如getA-->getB-->getA,B拿到A的纯净bean返回,接着A会初始化完,因为使用的是同一个对象引用,所以当我getA结束后,B.A自然也是成熟状态Bean了(这里其实也会拿到脏数据的,在spring中是通过两把锁跟二级缓存保证的每次都是成熟bean)
循环依赖中存在动态代理问题
第二种问题分析:这样写肯定是有问题的,因为我是在初始化后创建的动态代理,此时B的属性赋值的是原对象而不是代理对象
第一种问题分析:把动态代理的创建放在实例化之后,其实这个是没有问题的,也已经解决了AOP的问题(正常情况下在初始化后创建代理对象,此时这个代理对象因为没有属性赋值没有意义)。那为什么spring不在实例化后创建代理呢?可能spring还是希望没有循环依赖的bean还是在初始化后创建动态代理,只有循环依赖的情况才在实例化后创建代理对象,即使在初始化后创建代理对象同样可以通过二级缓存解决:因为一级缓存没有二级缓存有说明是循环依赖情况,所以可以在二级缓存中拿的时候创建代理对象,这也可以解决
第一种问题分析:把动态代理的创建放在实例化之后,其实这个是没有问题的,也已经解决了AOP的问题(正常情况下在初始化后创建代理对象,此时这个代理对象因为没有属性赋值没有意义)。那为什么spring不在实例化后创建代理呢?可能spring还是希望没有循环依赖的bean还是在初始化后创建动态代理,只有循环依赖的情况才在实例化后创建代理对象,即使在初始化后创建代理对象同样可以通过二级缓存解决:因为一级缓存没有二级缓存有说明是循环依赖情况,所以可以在二级缓存中拿的时候创建代理对象,这也可以解决
至于spring为什么还需要三级缓存,主要还是解耦吧,就是不把后置处理器的执行写到getSingleton(一个获取对象的方法中)
spring解决方案
总结流程
1.populateBean 调用 BeanFactroy.getBean("beanA") 以获取 beanB 的依赖。
2.getBean("beanB") 会先调用 getSingleton("beanA"),尝试从缓存中获取 beanA。此时由于 beanA 还没完全实例化好
3.于是 this.singletonObjects.get("beanA") 返回 null。
4.接着 this.earlySingletonObjects.get("beanA") 也返回空,因为 beanA 早期引用还没放入到这个缓存中。
5.最后调用 singletonFactory.getObject() 返回 singletonObject,此时 singletonObject != null。singletonObject 指向 BeanA@1234,也就是 createBeanInstance 创建的原始对象。此时 beanB 获取到了这个原始对象的引用,beanB 就能顺利完成实例化。beanB 完成实例化后,beanA 就能获取到 beanB 所指向的实例,beanA 随之也完成了实例化工作。由于 beanB.beanA 和 beanA 指向的是同一个对象 BeanA@1234,所以 beanB 中的 beanA 此时也处于可用状态了
2.getBean("beanB") 会先调用 getSingleton("beanA"),尝试从缓存中获取 beanA。此时由于 beanA 还没完全实例化好
3.于是 this.singletonObjects.get("beanA") 返回 null。
4.接着 this.earlySingletonObjects.get("beanA") 也返回空,因为 beanA 早期引用还没放入到这个缓存中。
5.最后调用 singletonFactory.getObject() 返回 singletonObject,此时 singletonObject != null。singletonObject 指向 BeanA@1234,也就是 createBeanInstance 创建的原始对象。此时 beanB 获取到了这个原始对象的引用,beanB 就能顺利完成实例化。beanB 完成实例化后,beanA 就能获取到 beanB 所指向的实例,beanA 随之也完成了实例化工作。由于 beanB.beanA 和 beanA 指向的是同一个对象 BeanA@1234,所以 beanB 中的 beanA 此时也处于可用状态了
当我们getBean的时候,首先会去一级缓存中找,找不到再调用createBean逻辑。在createBean中会将beanName添加到正在创建的集合列表中(表示这个bean正在创建),接着会在三级缓存singletonFactories中放入一个接口函数。当循环依赖B去getA的时候,getSingleton首先会判断一级缓存是否存在,不存在则到二级缓存中找,找不到再到三级缓存中找,因为前面不管怎么样都将早期对象包装成ObjectFactory(接口函数)放入到了三级缓存中,自然能拿到,也表示此时为循环依赖情况,接着调用ObjectFactory的getObject方法(就是那个接口函数),接口函数会传入我们早期bean,函数里面判断是否需要aop,需要则返回代理对象,不需要则直接原封不动返回传入的早期bean(调用createBean会调用bean的后置处理器,如果我们有aop的话就会创建代理对象返回,如果没有直接返回),最后将这个接口函数返回的对象放入到二级缓存中(这也表示只有在循环依赖的时候才会用到二级缓存),那放入二级缓存有啥用,我不放也不影响逻辑啊?确实不影响,只是这样就可以避免多次调用三级缓存的调用
看几个问题
Spring怎么解决循环依赖
通过三级缓存解决,首先,Spring内部维护了三个Map,也就是我们通常说的三级缓存。
singletonObjects 它是我们最熟悉的朋友,俗称“单例池”“容器”,缓存创建完成单例Bean的地方。
singletonFactories 映射创建Bean的原始工厂
earlySingletonObjects 映射Bean的早期引用,也就是说在这个Map里的Bean不是完整的,甚至还不能称之为“Bean”,只是一个Instance.
为什么要二级缓存和三级缓存
为什么需要二级缓存?
- 一级缓存和二级缓存相比:二级缓存只要是为了分离成熟Bean和纯净Bean(未注入属性)的存放, 防止多线程中在Bean还未创建完成时读取到的Bean时不完整的。所以也是为了保证我们getBean是完整最终的Bean,不会出现不完整的情况。
- 一二三级缓存下二级缓存的意义:二级缓存为了存储三级缓存的创建出来的早期Bean, 为了避免三级缓存重复执行
为什么需要三级缓存?
我们都知道Bean的aop动态代理创建时在初始化之后,但是循环依赖的Bean如果使用了AOP。 那无法等到解决完循环依赖再创建动态代理, 因为这个时候已经注入属性。所以如果循环依赖的Bean使用了aop. 需要提前创建aop,也就是循环依赖的时候spring的AOP在实例化后创建,没有循环依赖的时候在初始化后创建
Spring有没有解决构造函数的循环依赖
从流程图应该不难看出来,在Bean调用构造器实例化之前,一二三级缓存并没有Bean的任何相关信息,在实例化之后才放入三级缓存中,因此当getBean的时候缓存并没有命中,这样就抛出了循环依赖的异常了
Spring有没有解决多例下的循环依赖
为什么可以这么做,因为我们的bean是单例的,而且是字段注入(setter注入)的,单例意味着只需要创建一次对象,后面就可以从缓存中取出来,字段注入,意味着我们无需调用构造方法进行注入。如果是原型bean,那么就意味着每次都要去创建对象,无法利用缓存
Spring是怎样避免读取到不完整Bean的?
首先在容器初始化后是不会出现读取到不完整bean的,虽然说通过二级缓存将早期对象与成熟对象分离,但它并没有完全解决,完全解决还是得需要两把锁:
- 从缓存中拿加锁:初始化容器过程中通过在getSingleton方法中加了synchronized 锁,然后把整个Bean的创建过程也锁起来,锁的都是同一把锁(一级缓存对象)synchronized (this.singletonObjects)
- 然后在钩子方法getSingleton那里把整个createBean创建Bean的过程中使用了synchronized 包起来,非钩子中的createBean没有锁,因为只有循环依赖才需要避免获取不成熟bean
Spring AOP使用介绍-从前世到今生
spring aop与AspectJ
Spring AOP术语
- 通知(Advice)在切面的某个特定连接点上执行的动作,通俗一点说就是定义了“什么时候“和”什么时候做什么”。通知有很多种类型:前置通知(Before)、后置通知(AfterReturning)、异常通知(AfterThrowing)、最终通知(After)、环绕通知(Around)。
- 连接点(Join Point)是程序执行过程中能够应用通知的所有点。就好比切点表达式匹配的方法,真正需要增强的方法
- 切点(Poincut)是定义了在“什么地方”进行切入,哪些连接点会得到通知。显然,切点一定是连接点。就好比切点表达式
- 切面(Aspect)是通知和切点的结合。通知和切点共同定义了切面的全部内容——是什么,何时,何地完成功能。就是下面讲到的advisor,也是我们注解中使用@Aspect注解的类
- 引入(Introduction)允许我们向现有的类中添加新方法或者属性。
- 织入(Weaving)将通知切入到连接点的过程,分为编译期织入、类加载期织入和运行期织入。
Spring Aop说明
它基于动态代理来实现。默认地,如果使用接口的,用 JDK 提供的动态代理实现,如果没有接口,使用 CGLIB 实现。大家一定要明白背后的意思,包括什么时候会不用 JDK 提供的动态代理,而用 CGLIB 实现
Spring AOP 只能作用于 Spring 容器中的 Bean,它是使用纯粹的 Java 代码实现的,只能作用于 bean 的方法
Spring 延用了 AspectJ 中的概念,包括使用了 AspectJ 提供的 jar 包中的注解,但是不依赖于其实现功能
Spring AOP 的使用方法
- Spring 1.2 基于接口的配置:最早的 Spring AOP 是完全基于几个接口的,想看源码的同学可以从这里起步。
- Spring 2.0 schema-based 配置:Spring 2.0 以后使用 XML 的方式来配置,使用 命名空间 <aop ></aop>
- Spring 2.0 @AspectJ 配置:使用注解的方式来配置,这种方式感觉是最方便的,还有,这里虽然叫做 @AspectJ,但是这个和 AspectJ 其实没啥关系
AspectJ
AspectJ 能干很多 Spring AOP 干不了的事情,它是 AOP 编程的完全解决方案。Spring AOP 致力于解决的是企业级开发中最普遍的 AOP 需求(方法织入),而不是力求成为一个像 AspectJ 一样的 AOP 编程完全解决方案
AspectJ 在实际代码运行前完成了织入,所以大家会说它生成的类是没有额外运行时开销的
AspectJ直接将切面在【编译前、后】或【JVM加载的时候】进行织入到.class代码中。在实际生产中,我们用得最多的还是纯 Spring AOP,因为AspectJ学习成本高, Spring AOP已经能满足日常开发种的需求。 通过本AspectJ大家了解下 Spring Aop只用到了aspectj的设计理念(注解)和切点表达式配对
spring aop使用
Spring 1.2 基于接口的配置
拦截粒度为类级别案例
定义advice或Interceptor
先是把两个advice放到spring中去,同时也通过FactoryBean方式创建单个代理ProxyFactoryBean calculateProxy(),代理里面指定目标Target 以及多个advice,然后执行target的所有方法就会被代理对象增强
从结果可以看到,使用了责任链方式对advice和Interceptor都进行调用。这个例子理解起来应该非常简单,就是通过调用FactoryBean的getObject方法创建一个代理实现。
代理模式需要一个接口(可选)、一个具体实现类,然后就是定义一个代理类,用来包装实现类,添加自定义逻辑,在使用的时候,需要用代理类来生成实例
此中方法有个致命的问题,如果我们只能指定单一的Bean的AOP, 如果多个Bean需要创建多个ProxyFactoryBean 。而且,我们看到,我们的拦截器的粒度只控制到了类级别,类中所有的方法都进行了拦截
拦截粒度为方法级别案例
这里我们使用实现类 NameMatchMethodPointcutAdvisor 来演示,从名字上就可以看出来,它需要我们给它提供方法名字,这样符合该配置的方法才会做拦截
定义代理并指定advisor
我们可以看到,calculateProxy这个 bean(ProxyFactoryBean ) 配置了一个 advisor,advisor 内部有一个 advice。advisor 负责匹配方法,内部的 advice 负责实现方法包装。
注意,这里的 mappedNames 配置是可以指定多个的,用逗号分隔,可以是不同类中的方法(因为这还没有说得是具体某个类,只是在后面ProxyFactoryBean指定的target,所以可以是不同类的方法,只要后面target中有就拦截)。相比直接指定 advice,advisor 实现了更细粒度的控制,因为在这里配置 advice 的话,所有方法都会被拦截
自动代理方式案例
不管粒度为类级别还是方法级别,它们有个共同的问题,那就是我们得为每个 bean 都配置一个代理,之后获取 bean 的时候需要获取这个代理类的 bean 实例(如 上面ctx.getBean("calculateProxy",Calculate.class)),是获取的calculateProxy(ProxyFactoryBean)代理类,这显然非常不方便,不利于我们之后要使用的自动根据类型注入
autoproxy:从名字我们也可以看出来,它是实现自动代理,也就是说当 Spring 发现一个 bean 需要被切面织入的时候,Spring 会自动生成这个 bean 的一个代理来拦截方法的执行,确保定义的切面能被执行。这里强调自动,也就是说 Spring 会自动做这件事,而不用像前面介绍的,我们需要显式地获取代理类的 bean。我们去掉原来的 ProxyFactoryBean 的配置,改为使用 BeanNameAutoProxyCreator 来配置:
@Bean
public BeanNameAutoProxyCreator autoProxyCreator() {
BeanNameAutoProxyCreator beanNameAutoProxyCreator = new BeanNameAutoProxyCreator();
//设置要创建代理的那些Bean的名字
beanNameAutoProxyCreator.setBeanNames("my*");//有点类似切点表达式,只不过这里为正则表达式且是针对类的
//设置拦截链名字(这些拦截器是有先后顺序的)
beanNameAutoProxyCreator.setInterceptorNames("myLogInterceptor");
return beanNameAutoProxyCreator;
}
这里的 InterceptorNames 和前面一样,也是可以配置成 Advisor 和 Interceptor 的,,也就是说我们可以跟上面一样通过设置一个NameMatchMethodPointcutAdvisor(设置了哪些方法、增强方法advice)到BeanNameAutoProxyCreator里面
public BeanNameAutoProxyCreator autoProxyCreator() {
BeanNameAutoProxyCreator beanNameAutoProxyCreator = new BeanNameAutoProxyCreator();
//设置要创建代理的那些Bean的名字
beanNameAutoProxyCreator.setBeanNames("my*");//有点类似切点表达式,只不过这里为正则表达式且是针对类的
//设置拦截链名字(这些拦截器是有先后顺序的)
beanNameAutoProxyCreator.setInterceptorNames("myLogInterceptor");
return beanNameAutoProxyCreator;
}
这里的 InterceptorNames 和前面一样,也是可以配置成 Advisor 和 Interceptor 的,,也就是说我们可以跟上面一样通过设置一个NameMatchMethodPointcutAdvisor(设置了哪些方法、增强方法advice)到BeanNameAutoProxyCreator里面
Spring 2.0 @AspectJ 配置
spring5.2.9版本通知方法的执行顺序
环绕before-->普通before-->目标方法执行-->环绕afterReturning/环绕after/出现异常(看你怎么写)-->普通afterReturning或者异常AfterThrowing-->普通after
1、正常执行:@Before--->@AfterReturning--->@After
2、异常执行:@Before--->@AfterThrowing--->@After
2、异常执行:@Before--->@AfterThrowing--->@After
@Aspect
public class LogUtil {
/**
* 方法执行前执行
* @param joinPoint
*/
@Before("execution( public int lb.study.tulin.springaoptest.MyCalculate.*(int,int))")
public static void start(JoinPoint joinPoint){
Object[] args = joinPoint.getArgs();
String name = joinPoint.getSignature().getName();
System.out.println(name+"方法开始执行,参数是:"+ Arrays.asList(args));
}
}
@Before前置通知并获取方法参数
@After后置通知,方法执行后执行
@AfterReturning,方法执行后,且能拿到执行结果
@AfterThrowing方法出现异常执行
@Around环绕通知
@After后置通知,方法执行后执行
@AfterReturning,方法执行后,且能拿到执行结果
@AfterThrowing方法出现异常执行
@Around环绕通知
Spring 2.0 schema-based 配置(xml)
spring aop切面解析源码分析
通过advisor回顾分析aop架构
前面spring aop的使用的时候,一开始我们spring只提供简单的aop使用--基于接口的方式,一点也不方便使用起来。但是这个是能帮助我们理解advisor跟怎么看源码的
首先我们这里定义了一个NameMatchMethodPointcutAdvisor ,advisor里面有我们的advice(通知)跟Pointcut(切点)。
@Bean
public NameMatchMethodPointcutAdvisor myLogAspect() {
NameMatchMethodPointcutAdvisor advisor=new NameMatchMethodPointcutAdvisor();
// 通知(Advice) :是我们的通知类
// 通知者(Advisor):是经过包装后的细粒度控制方式。
advisor.setAdvice(myLogAdvice());
advisor.setMappedNames("div","add");
return advisor;
}
@Bean
public NameMatchMethodPointcutAdvisor myLogAspect() {
NameMatchMethodPointcutAdvisor advisor=new NameMatchMethodPointcutAdvisor();
// 通知(Advice) :是我们的通知类
// 通知者(Advisor):是经过包装后的细粒度控制方式。
advisor.setAdvice(myLogAdvice());
advisor.setMappedNames("div","add");
return advisor;
}
而Advice就是通知,比如前置通知@Before,它是一个类,所以我们可以猜到在spring里面,每个advice(通知)都会对应一个bean
public class MyLogAdvice implements MethodBeforeAdvice {
@Override
public void before(Method method, Object[] args, Object target) throws Throwable {
String methodName = method.getName();
System.out.println("执行目标方法【"+methodName+"】的<前置通知>,入参"+ Arrays.asList(args));
}
}
然后看看注解版的写法:可以从@Before看出会有一个Before类型的advice,然后@Before又有execution表达式(Pointcut),也就是说@Before("execution(....)")包含了advice(通知)跟Pointcut(切点)。,所以我们可以猜测,spring会为每个@Before("execution(....)")生成一个advisor对象,或者包装起来
@Aspect
public class LogUtil {
/**
* 方法执行前执行
* @param joinPoint
*/
@Before("execution( public int lb.study.tulin.springaoptest.MyCalculate.*(int,int))")
public static void start(JoinPoint joinPoint){
Object[] args = joinPoint.getArgs();
String name = joinPoint.getSignature().getName();
System.out.println(name+"方法开始执行,参数是:"+ Arrays.asList(args));
}
}
spring aop源码执行过程分析
我们知道,spring中的aop是通过动态代理实现的,那么他具体是如何实现的呢?spring通过一个切面类,在他的类上加入@Aspect注解,定义一个Pointcut方法,最后定义一系列的增强方法。这样就完成一个对象的切面操作
那么思考一下,按照上述的基础,要实现我们的aop,大致有以下思路:
1.找到所有的切面类
2.解析出所有的advice(通知)跟pointcut(切点)并保存
3.根据pointcut匹配到bean,然后为bean创建一个动态代理类
4.调用被代理类的方法时,找到他的所有增强器,并增强当前的方法
1.找到所有的切面类
2.解析出所有的advice(通知)跟pointcut(切点)并保存
3.根据pointcut匹配到bean,然后为bean创建一个动态代理类
4.调用被代理类的方法时,找到他的所有增强器,并增强当前的方法
源码整体执行流程图
入口:@EnableAspectJAutoProxy注解会注册一个AspectJAutoProxyRegistrar(实现了ImportBeanDefinitionRegistrar),在解析import注解的时候会调用其registerBeanDefinitions方法,方法里主要是注册了一个InstantiationAwareBeanPostProcessor类型的bean后置处理器(在bean实例化前调用)AnnotationAwareAspectJAutoProxyCreator,在调用后置处理器的postProcessBeforeInstantiation()实现解析我们的aop。
解析:解析结果就是一个advisor集合。老版本接口方式:直接循环找到实现了advisor接口的names,然后通过工厂获取bean添加到返回集合List<Advisor>;注解方式就复杂些:通过object类,代表去容器中获取到所有的组件的名称,然后再经过一一的进行遍历(耗时,通过缓存解决,但事务解析并不会有缓存),通过beanName去容器中获取类对象,然后判断是否是切面(类是否声明了@Aspect注解),同时会通过保存类名到集合中,表示我已经解析过了,也就只有第一次创建bean的时候调用的后置处理器才会进行解析aop。获取到我们需要解析的切面类后,再循环解析声明了Before、After等注解的方法,每个方法创建成一个advisor(创建不同类型的advice,创建pointcut),添加到List<Advisor>集合
解析:解析结果就是一个advisor集合。老版本接口方式:直接循环找到实现了advisor接口的names,然后通过工厂获取bean添加到返回集合List<Advisor>;注解方式就复杂些:通过object类,代表去容器中获取到所有的组件的名称,然后再经过一一的进行遍历(耗时,通过缓存解决,但事务解析并不会有缓存),通过beanName去容器中获取类对象,然后判断是否是切面(类是否声明了@Aspect注解),同时会通过保存类名到集合中,表示我已经解析过了,也就只有第一次创建bean的时候调用的后置处理器才会进行解析aop。获取到我们需要解析的切面类后,再循环解析声明了Before、After等注解的方法,每个方法创建成一个advisor(创建不同类型的advice,创建pointcut),添加到List<Advisor>集合
spring aop代理对象创建以及调用invoke源码
代理对象创建以及调用invoke源码导读说明
代理对象创建源码导读说明
前面我们知道了spring aop创建代理对象时机:如果存在循环依赖,则在实例化后创建;如果不存在循环依赖,则在初始化后创建,对应着BeanPostProcessor.postProcessAfterInitialization()方法。只需要研究正常bean的代理就行了,循环依赖的bean也差不多流程,而且我们在doCreateBean之前就已经解析好了我们的切面Aspect,接着我们在getBean的时候spring就会一个一个的去比较要不要创建代理对象,如果要就创建,放入bean缓存池中,也就是<beanName,代理对象>,当我们getBean的时候其实拿到的是代理对象,调用方法也就调用到了代理对象的方法
invoke执行目标方法源码导读说明
在目标对象调用方法后,我们getBean的时候因为单例池中放的其实是代理对象,所有拿到的自然也是代理对象,使得方法调用到动态代理对象的加强方法上
不使用(exposeProxy = true)默认为false,在目标方法里面执行另外被拦截增强的方法,不会继续拦截执行
exposeProxy = true使用说明
使用(exposeProxy = true)+((Calculate) AopContext.currentProxy()).div(numA,numB);//必须配合(exposeProxy = true)使用
exposeProxy = true会将代理对象放入到线程变量中,通过AopContext.currentProxy()可以拿到,也就会被增强了
exposeProxy = true会将代理对象放入到线程变量中,通过AopContext.currentProxy()可以拿到,也就会被增强了
cglib跟jdk代理区别
cglib:使用的是继承的方式,当调用目标方法的时候,是调用的子类(代理对象)的方法,自然是加强了,方法里调用也是调用到代理对象的方法,所以在cglib中是会重复调用的
jdk代理:通过反射调用目标方法,所以在方法里面调用也是调用的目标对象的方法并不是代理对象的方法,所以并不会重复调用
关于spring中cglib并不会重复调用的解释:我测试了在spring5.29版本中,在cglib的情况下(强制使用cglib:@EnableAspectJAutoProxy(proxyTargetClass = true))上面的案例没有执行区别。原因在于spring中是通过advisor去调用的目标方法(advisor里面保存了目标对象),并没有直接调用代理对象的方法,自然方法里面的调用并不会加强
aop代理对象创建与调用源码讲解
代理创建
入口:@EnableAspectJAutoProxy注解会注册一个AspectJAutoProxyRegistrar(实现了ImportBeanDefinitionRegistrar),在解析import注解的时候会调用其registerBeanDefinitions方法,方法里主要是注册了一个InstantiationAwareBeanPostProcessor类型的bean后置处理器(在bean实例化前调用)AnnotationAwareAspectJAutoProxyCreator,在实例化前调用后置处理器的postProcessBeforeInitialization()实现解析我们的aop,非循环依赖在初始化后调用postProcessAfterInitialization()创建我们的动态代理
刷选合适advisor集合:首先获取到前面解析到的所有List<Advisor>缓存,匹配规则也不难,首先进行类级别刷选,再进行方法级别刷选(循环当前class包括它父类的所有方法),匹配方式是通过Aspect的方式匹配,这也说明了spring只是用到了Aspect的匹配方法而已。另外也有一个关键地方:这里会对我们的advisor排序,从而实现调用的时候按照advice通知类型执行
代理创建:如果前面刷选advisor集合不为空,说明需要增强创建动态代理对象。而且会在Map缓存中放入一个标识防止重复创建代理。如果ProxyTargetClass=false且targetClass对象实现了接口,走jdk动态代理,否则走cglib动态代理
invoke调用
代理invoke调用:因为IOC一级缓存放入的为代理对象,自然我们拿到的就是代理对象。如果设置了exposeProxy = true,会把我们的代理对象暴露到线程变量中,这里有几种情况不会增强:执行方法为equals或hashCode方法不会增强,执行的class对象是DecoratingProxy类型 也不要拦截器执行。使用责任链模式调用通知,通知执行顺序:环绕before-->普通before-->目标方法执行-->环绕afterReturning/环绕after/出现异常(看你怎么写)-->普通afterReturning或者异常AfterThrowing-->普通after
Spring 事务管理
声明式事务跟编程式事务
编程式事务:在代码中直接加入处理事务的逻辑,可能需要在代码中显式调用beginTransaction()、commit()、rollback()等事务管理相关的方法
声明式事务:在方法的外部添加注解或者直接在配置文件中定义,将事务管理代码从业务方法中分离出来,以声明的方式来实现事务管理。spring的AOP恰好可以完成此功能:事务管理代码的固定模式作为一种横切关注点,通过AOP方法模块化,进而实现声明式事务。
spring事务管理器
Spring从不同的事务管理API中抽象出了一整套事务管理机制,让事务管理代码从特定的事务技术中独立出来。开发人员通过配置的方式进行事务管理,而不必了解其底层是如何实现的。
Spring的核心事务管理抽象是PlatformTransactionManager。它为事务管理封装了一组独立于技术的方法。无论使用Spring的哪种事务管理策略(编程式或声明式),事务管理器都是必须的
事务配置的属性
isolation:设置事务的隔离级别
propagation:事务的传播行为
noRollbackFor:那些异常事务可以不回滚
noRollbackForClassName:填写的参数是全类名
rollbackFor:哪些异常事务需要回滚
rollbackForClassName:填写的参数是全类名
readOnly:设置事务是否为只读事务
timeout:事务超出指定执行时长后自动终止并回滚,单位是秒
propagation:事务的传播行为
noRollbackFor:那些异常事务可以不回滚
noRollbackForClassName:填写的参数是全类名
rollbackFor:哪些异常事务需要回滚
rollbackForClassName:填写的参数是全类名
readOnly:设置事务是否为只读事务
timeout:事务超出指定执行时长后自动终止并回滚,单位是秒
设置隔离级别(isolation)
隔离级别是数据库的,spring只是去帮忙设置,毕竟隔离级别是会话级别的
事务的传播特性(7种)
事务的传播特性指的是当一个事务方法被另一个事务方法调用时,这个事务方法应该如何进行?
事务传播行为类型 外部不存在事务 外部存在事务 使用方式
REQUIRED(默认) 开启新的事务 融合到外部事务中 @Transactional(propagation = Propagation.REQUIRED)适用增删改查
SUPPORTS 不开启新的事务 融合到外部事务中 @Transactional(propagation = Propagation.SUPPORTS)适用查询
REQUIRES_NEW 开启新的事务 挂起外部事务,创建新的事务 @Transactional(propagation = Propagation.REQUIRES_NEW)适用内部事务和外部事务不存在业务关联情况,如日志
NOT_SUPPORTED 不开启新的事务 挂起外部事务 @Transactional(propagation = Propagation.NOT_SUPPORTED)不常用
NEVER 不开启新的事务 抛出异常 @Transactional(propagation = Propagation.NEVER )不常用
MANDATORY 抛出异常 融合到外部事务中 @Transactional(propagation = Propagation.MANDATORY)不常用
NESTED 开启新的事务 融合到外部事务中,SavePoint机制,外层影响内层, 内层不会影响外层 @Transactional(propagation = Propagation.NESTED)不常用
REQUIRED(默认) 开启新的事务 融合到外部事务中 @Transactional(propagation = Propagation.REQUIRED)适用增删改查
SUPPORTS 不开启新的事务 融合到外部事务中 @Transactional(propagation = Propagation.SUPPORTS)适用查询
REQUIRES_NEW 开启新的事务 挂起外部事务,创建新的事务 @Transactional(propagation = Propagation.REQUIRES_NEW)适用内部事务和外部事务不存在业务关联情况,如日志
NOT_SUPPORTED 不开启新的事务 挂起外部事务 @Transactional(propagation = Propagation.NOT_SUPPORTED)不常用
NEVER 不开启新的事务 抛出异常 @Transactional(propagation = Propagation.NEVER )不常用
MANDATORY 抛出异常 融合到外部事务中 @Transactional(propagation = Propagation.MANDATORY)不常用
NESTED 开启新的事务 融合到外部事务中,SavePoint机制,外层影响内层, 内层不会影响外层 @Transactional(propagation = Propagation.NESTED)不常用
超时属性(timeout)
指定事务等待的最长时间(秒),当前事务访问数据时,有可能访问的数据被别的数据进行加锁的处理,那么此时事务就必须等待,如果等待时间过长给用户造成的体验感差
设置事务只读(readOnly)
readonly:只会设置在查询的业务方法中
connection.setReadOnly(true) 通知数据库,当前数据库操作是只读,数据库就会对当前只读做相应优化
connection.setReadOnly(true) 通知数据库,当前数据库操作是只读,数据库就会对当前只读做相应优化
异常属性
设置 当前事务出现的那些异常就进行回滚或者提交。
spring声明式事务管理默认对非检查型异常和运行时异常进行事务回滚,而对检查型异常则不进行回滚操作
1、继承自RuntimeException或Error的是非检查型异常==>>回滚
2、继承自Exception的则是检查型异常(RuntimeException本身也是Exception的子类)==>>提交
常见的RuntimeException
1、NullPointerException:一般都是在null对象上调用方法了。
2、NumberFormatException:继承IllegalArgumentException(非法参数),字符串转换为数字时出现。
3、ArrayIndexOutOfBoundsException:数组越界。
4、StringIndexOutOfBoundsException:字符串越界。比如 String s="hello"; char c=s.chatAt(6);
5、ClassCastException:类型转换错误。比如 Object obj=new Object(); String s=(String)obj;
6、UnsupportedOperationException:该操作不被支持。有可能子类中不想支持父类中有的方法,可以直接抛出
7、ArithmeticException:算术错误,典型的就是0作为除数的时候。
1、继承自RuntimeException或Error的是非检查型异常==>>回滚
2、继承自Exception的则是检查型异常(RuntimeException本身也是Exception的子类)==>>提交
常见的RuntimeException
1、NullPointerException:一般都是在null对象上调用方法了。
2、NumberFormatException:继承IllegalArgumentException(非法参数),字符串转换为数字时出现。
3、ArrayIndexOutOfBoundsException:数组越界。
4、StringIndexOutOfBoundsException:字符串越界。比如 String s="hello"; char c=s.chatAt(6);
5、ClassCastException:类型转换错误。比如 Object obj=new Object(); String s=(String)obj;
6、UnsupportedOperationException:该操作不被支持。有可能子类中不想支持父类中有的方法,可以直接抛出
7、ArithmeticException:算术错误,典型的就是0作为除数的时候。
声明式事务跟编程式事务
声明式事务管理
@Transactional注解应该写在哪
@Transactional 可以标记在类上面(当前类所有的方法都运用上了事务)
@Transactional 标记在方法则只是当前方法运用事务
也可以类和方法上面同时都存在, 如果类和方法都存在@Transactional会以方法的为准。如果方法上面没有@Transactional会以类上面的为准
建议:@Transactional写在方法上面,控制粒度更细, 建议@Transactional写在业务逻辑层上,因为只有业务逻辑层才会有嵌套调用的情况
@Transactional 标记在方法则只是当前方法运用事务
也可以类和方法上面同时都存在, 如果类和方法都存在@Transactional会以方法的为准。如果方法上面没有@Transactional会以类上面的为准
建议:@Transactional写在方法上面,控制粒度更细, 建议@Transactional写在业务逻辑层上,因为只有业务逻辑层才会有嵌套调用的情况
编程式事务管理
Spring框架提供两种方式来进行编程式事务管理:
- TransactionTemplate.
- PlatformTransactionManager 的实现。
spring声明式事务源码
源码导读说明
事务的使用就不说了,我这里看的是javaconfig的事务,也就是注解版的spring声明事务使用。前面我们知道spring事务有两种写法:一种声明式事务,一种编程式事务。声明式也就是通过@Transaction声明使用的。下面涉及的都是声明式事务相关
能猜出来它里面肯定是通过aop去实现的,前面aop的源码的时候,知道了aop会有很多的advisor,advisor又包含两个重要的组成部分:advice(通知)跟pointcut(切点)。advice通知就是具体去增强的逻辑,通过pointcut就能知道哪里需要加强。所以我们可以先猜一下:事务肯定是基于aop实现的,然后就是通过在方法执行前先开启事务,然后执行目标方法,最后再根据目标方法的执行决定是commit或者是Rollback,那么这个pointcut就是能够帮我们去找到哪些地方使用了@Transaction声明。还有就是spring是通过直接声明了advisor(也就是基于接口的aop写法),这种内置的advisor没必要通过注解的繁琐操作创建
嵌套调用讲解
在同一个类的A、B两个方法都声明了事务,就算设置了开启新事务,挂起外部事务,也不会生效,因为它压根就不会调用到代理对象,跟前面说的aop一样的道理的。如果是不同的类,自然就可以被增强。同一个类中只能通过把代理对象暴露在线程变量里,然后拿出来调用:这样就能达到想要的结果了
spring声明式源码分析
入口:通过@EnableTransactionManagement注解中使用@import创建了TransactionManagementConfigurationSelector类,TransactionManagementConfigurationSelector里面创建了两个重要的bean:
- AutoProxyRegistrar:实现了ImportBeanDefinitionRegistrar,在其registerBeanDefinitions方法中会判断如果只是加了@EnableTransactionManagement注解的话注册的是InfrastructureAdvisorAutoProxyCreator.class,如果同时加了@EnableAspectJAutoProxy注解的话,注册的是AnnotationAwareAspectJAutoProxyCreator.class(替换成这个)。因为他们的name是一样的。因为本身他们的方法实现都是一样的,比如在解析的时候都是来自AbstractAutoProxyCreator.postProcessBeforeInitialization(...)方法,spring当然不会重复注册两个,也就是注册了一个InstantiationAwareBeanPostProcessor(bean的后置处理器,bean实例化后解析,bean初始化后创建代理)
- ProxyTransactionManagementConfiguration:直接编程式aop方式创建了advice跟advisor以及一个类似pointcut的事务解析器用来解析事务注解
解析跟创建:解析就是缓存好advisor集合,事务相关的只有前面ProxyTransactionManagementConfiguration创建的一个。代理创建也一样没什么好说的,跟前面aop一样,区别在于过滤advisor的时候,类刷选、方法刷选都是同一个方法,都是判断是否存在@Transactional注解,满足说明此类有事务注解需要事务增强,创建动态代理
调用invoke:执行跟aop都差不多,区别在于advice的不同,执行advice方法的时候也不同。无非就是在方法前开启事务(当然也会有很多判断,判断是否嵌套事务,传播机制等等,大量使用了ThreadLocal变量),然后try起来commit还是rollback,有意思的是数据库的connection都是spring事务创建的而且会放到事务同步管理器中的本地线程变量中,这样我们mybatis拿到用也就整合spring事务成功了
spring5新特性
JDK版本升级
Spring 5的代码基于Java 8的语法规范,因此要想使用Spring 5,JDK的版本至少要在8.0以上。最开始的时候Spring 5.0想使用Java 9,但是Java 9发布的时间比Spring 慢了18个月,然后Spring开发团队决定从Spring 5.0中去除Java 9的依赖
spring-webflux /flʌks/:反应式编程模型
Spring 5 Framework 基于一种反应式基础而构建,而且是完全异步和非阻塞的。只需少量的线程,新的事件循环执行模型就可以垂直扩展。
该框架采用反应式流来提供在反应式组件中传播负压的机制。负压是一个确保来自多个生产者的数据不会让使用者不堪重负的概念。
Spring WebFlux 是 Spring 5 的反应式核心,它为开发人员提供了两种为 SpringWeb 编程而设计的编程模型:一种基于注解的模型和 Functional Web Framework(WebFlux.fn)。
基于注解的模型是 Spring WebMVC 的现代替代方案,该模型基于反应式基础而构建,而 FunctionalWeb Framework 是基于 @Controller 注解的编程模型的替代方案。这些模型都通过同一种反应式基础来运行,后者调整非阻塞 HTTP 来适应反应式流 API
kotlin函数式编程
Spring 5 的新函数式方法将请求委托给处理函数,这些函数接受一个服务器请求实例并返回一种反应式类型
spring jcl
代替了标准的Common Logging,同时它还可以自动的检测Log4J2.x,SLF4J,JUL(java.util.logging),而不需要额外的依赖。
支持junit5
JUnit 和 Spring 5:Spring 5 全面接纳了函数式范例,并支持 JUnit 5 及其新的函数式测试风格。还提供了对JUnit 4 的向后兼容性,以确保不会破坏旧代码。
Spring 5 的测试套件通过多种方式得到了增强,但最明显的是它对JUnit 5 的支持。现在可以在您的单元测试中利用Java 8 中提供的函数式编程特性
Spring 5 的测试套件通过多种方式得到了增强,但最明显的是它对JUnit 5 的支持。现在可以在您的单元测试中利用Java 8 中提供的函数式编程特性
HTTP/2 支持
Spring Framework 5.0 将提供专门的 HTTP/2 特性支持,还支持人们期望出现在JDK 9 中的新 HTTP 客户端。尽管 HTTP/2 的服务器推送功能已通过 Jetty servlet引擎的 ServerPushFilter 类向 Spring 开发人员公开了很长一段时间,但如果发现Spring 5 中开箱即用地提供了 HTTP/2性能增强,Web 优化者们一定会为此欢呼雀跃。
springMvc结构与使用回顾
MVC模型 的由来
Model1 模型
Model1 模型是很早以前项目开发的一种常见模型,项目主要由 jsp 和 JavaBean 两部分组成。
它的优点是:结构简单。开发小型项目时效率高。
它的缺点也同样明显:
第一:JSP 的职责兼顾于展示数据和处理数据(也就是干了控制器和视图的事)
第二:所有逻辑代码都是写在 JSP 中的,导致代码重用性很低。
第三:由于展示数据的代码和部分的业务代码交织在一起,维护非常不便。
所以,结论是此种设计模型已经被淘汰没人使用了
它的缺点也同样明显:
第一:JSP 的职责兼顾于展示数据和处理数据(也就是干了控制器和视图的事)
第二:所有逻辑代码都是写在 JSP 中的,导致代码重用性很低。
第三:由于展示数据的代码和部分的业务代码交织在一起,维护非常不便。
所以,结论是此种设计模型已经被淘汰没人使用了
在Model 1模式下,整个Web应用几乎全部由JSP页面组成,JSP页面接收处理客户端请求,对请求处理后直接做出响应。用少量的JavaBean来处理数据库连接、数据库访问等操作。
就像刚学java的时候,jsp本来就是servlet,它本来就能处理,也能写java代码,这种模式就是说控制器逻辑全在jsp中写
Model2 模型
Model2 模型是在 Model1 的基础上进行改良,它是 MVC 模型的一个经典应用。它把处理请求和展示数据进行分离,让每个部分各司其职。
此时的 JSP 已经就是纯粹的展示数据了,而处理请求的事情交由控制器来完成,使每个组件充分独立,提高了代码可重用性和易维护性
此时的 JSP 已经就是纯粹的展示数据了,而处理请求的事情交由控制器来完成,使每个组件充分独立,提高了代码可重用性和易维护性
Model 2是基于MVC架构的设计模式
在Model 2架构中,Servlet作为前端控制器,负责接收客户端发送的请求
在Servlet中只包含控制逻辑和简单的前端处理;后端JavaBean来完成实际的逻辑处理;最后,转发到相应的JSP页面处理显示逻辑
在Model 2架构中,Servlet作为前端控制器,负责接收客户端发送的请求
在Servlet中只包含控制逻辑和简单的前端处理;后端JavaBean来完成实际的逻辑处理;最后,转发到相应的JSP页面处理显示逻辑
Model 2具有组件化的特点,更适用于大规模应用的开发。
基于 MVC 模型框架之:SpringMVC
SpringMVC 的执行过程分析
1)前端控制器DispatcherServlet /dɪˈspætʃər/由框架提供作用:接收请求,处理响应结果
2)处理器映射器HandlerMapping由框架提供作用:根据请求URL,找到对应的Handler
3)处理器适配器HandlerAdapter由框架提供作用:调用处理器(Handler也就是Controller)的方法
4)处理器Handler又名Controller,后端处理器作用:接收用户请求数据,调用业务方法处理请求
5)视图解析器ViewResolver由框架提供作用:视图解析,把逻辑视图名称解析成真正的物理视图。支持多种视图技术:JSTLView,FreeMarker...
6)视图View,程序员开发作用:将数据展现给用户
2)处理器映射器HandlerMapping由框架提供作用:根据请求URL,找到对应的Handler
3)处理器适配器HandlerAdapter由框架提供作用:调用处理器(Handler也就是Controller)的方法
4)处理器Handler又名Controller,后端处理器作用:接收用户请求数据,调用业务方法处理请求
5)视图解析器ViewResolver由框架提供作用:视图解析,把逻辑视图名称解析成真正的物理视图。支持多种视图技术:JSTLView,FreeMarker...
6)视图View,程序员开发作用:将数据展现给用户
SpringMVC 中三大组件详解
处理器映射器-HandlerMapping
是在 Spring 的 3.1 版本之后加入的。它的出现,可以让使用者更加轻松的去配置 SpringMVC 的请求路径映射。去掉了早期繁琐的 xml 的配置
它的配置有两种方式:都是在 springmvc.xml 中加入配置。
第一种方式:
<bean class="org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping"/>
第二种方式:
<mvc:annotation-driven></mvc:annotation-driven>
第一种方式:
<bean class="org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping"/>
第二种方式:
<mvc:annotation-driven></mvc:annotation-driven>
处理器适配器-HandlerAdapter
要清晰的认识 SpringMVC 的处理器适配器,就先必须知道适配器以及它的作用,比如将不同的接口都转成USB接口这就是适配器
带入到我们 SpringMVC 中,就是把不同的控制器,最终都可以看成是适配器类型,从而执行适配器中定义的方法。更深层次的是,我们可以把公共的功能都定义在适配器中,从而减少每种控制器中都有的重复性代码
学习了SpringMVC 的执行过程,最终调用的是前端控制器 DispatcherServlet 的doDispatch 方法,而该方法中的 HandlerAdapter 的 handle 方法实际调用了我们自己写的控制器方法。而我们写的控制方法名称各不一样,它是通过 handle 方法反射调用的。但是我们不知道的是,其实 SpringMVC 中处理器适配器也有多个
视图解析器-View Resolver
首先,我们得先了解一下 SpringMVC 中的视图。视图的作用是渲染模型数据,将模型里的数据以某种形式呈现给客户。为了实现视图模型和具体实现技术的解耦,Spring 在 org.springframework.web.servlet 包中定义了一个高度抽象的 View 接口。我们的视图是无状态的,所以他们不会有线程安全的问题。无状态是指对于每一个请求,都会创建一个 View对象
比如JSON 视图 MappingJackson2JsonView 将模型数据封装成Json格式数据输出。它需要借助 Jackson 开源框架。
比如JSON 视图 MappingJackson2JsonView 将模型数据封装成Json格式数据输出。它需要借助 Jackson 开源框架。
常用注解的使用场景及实现思路分析
@RequestParam
application/x-www-form-urlencoded 或者 application/json 的情况下,无论 get/post/put/delete 请求方式,参数的体现形式都是 key=value。SpringMVC 是使用我们控制器方法的形参作为参数名称,再使用 request 的getParameterValues 方法获取的参数。所以才会有请求参数的 key 必须和方法形参变量名称保持一致的要求。但是如果形参变量名称和请求参数的 key 不一致呢?此时,参数将无法封装成功。此时 RequestParam 注解就会起到作用,它会把该注解 value 属性的值作为请求参数的 key 来获取请求参数的值,并传递给控制器方法
@RequestBody
SpringMVC 在封装请求参数的时候,默认只会获取参数的值,而不会把参数名称一同获取出来,这在我们使用表单提交的时候没有任何问题。因为我们的表单提交,请求参数是key=value 的。但是当我们使用 ajax 进行提交时,请求参数可能是 json 格式的:{key:value},在此种情况下,要想实现封装以我们前面的内容是无法实现的。此时需要我们使用@RequestBody 注解
@PathVariable
是 SpringMVC 在 3.0 之后新加入的一个注解,是 SpringMVC 支持 Restful 风格 URL 的一个重要标志。注解的作用大家已经非常熟悉了,就是把藏在请求 URL 中的参数,给我们控制器方法的形参赋值
拦截器的 AOP 思想
AOP 思想是 Spring 框架的两大核心之一,是解决方法调用依赖以及提高方便后期代码维护的重要思想。它是把我们代码中高度重复的部分抽取出来,并在适当的时机,通过代理机制来执行,从而做到不修改源码对已经写好的方法进行增强。而拦截器正是这种思想的具体实现
自定义拦截器中三个方法说明及使用场景
preHandle
此方法的执行时机是在控制器方法执行之前,所以我们通常是使用此方法对请求部分进行增强。同时由于结果视图还没有创建生成,所以此时我们可以指定响应的视图
此方法的执行时机是在控制器方法执行之前,所以我们通常是使用此方法对请求部分进行增强。同时由于结果视图还没有创建生成,所以此时我们可以指定响应的视图
postHandle
此方法的执行时机是在控制器方法执行之后,结果视图创建生成之前。所以通常是使用此方法对响应部分进行增强。因为结果视图没有生成,所以我们此时仍然可以控制响应结果
此方法的执行时机是在控制器方法执行之后,结果视图创建生成之前。所以通常是使用此方法对响应部分进行增强。因为结果视图没有生成,所以我们此时仍然可以控制响应结果
afterCompletion
此方法的执行时机是在结果视图创建生成之后,展示到浏览器之前。所以此方法执行时,本次请求要准备的数据已生成完毕,且结果视图也已创建完成,所以我们可以利用此方法进行清理操作。同时,我们也无法控制响应结果集内容(因为已经完成返回了还怎么改)
此方法的执行时机是在结果视图创建生成之后,展示到浏览器之前。所以此方法执行时,本次请求要准备的数据已生成完毕,且结果视图也已创建完成,所以我们可以利用此方法进行清理操作。同时,我们也无法控制响应结果集内容(因为已经完成返回了还怎么改)
父子容器
Bean 被创建两次 ?
Spring 的 IOC 容器不应该扫描 SpringMVC 中的 bean, 对应的SpringMVC 的 IOC 容器不应该扫描 Spring 中的 bean
在 Spring MVC 配置文件中引用业务层的 Bean
- 多个 Spring IOC 容器之间可以设置为父子关系,以实现良好的解耦。
- Spring MVC WEB 层容器可作为Spring容器( “业务层” )的子容器:即 WEB 层容器可以引用业务层容器的 Bean,而业务层容器却访问不到 WEB 层容器的 Bean
其实在springboot中service层是可以注入controller的,但是他们是一个controller对象,也就是说会把springmvc容器中的bean全部注入到springioc中
为什么要分父子容器
业务隔离,controller跟service隔离开
防止bean创建两次,因为使用的相同注解,不应该扫描对面的bean
方便子容器的切换。如果现在我们想把web层从spring mvc替换成struts,那么只需要将springmvc.xml替换成
Struts的配置文件struts.xml即可,而spring-core.xml不需要改变
springMVC关键源码
入口:tomcat在处理用户请求的时候会调用到web.xml中配置的前端控制器DispatcherServlet的onRefresh方法跟service方法:
onRefresh方法:初始化我们的spring mvc九大组件,比如初始化我们的HandlerMapping(比如requestMappingHandlerMapping 用于处理我们的@RequestMapping)、例化我们的HandlerAdapters
service方法:根据我们的请求调用对应的controller中的方法
onRefresh方法:初始化我们的spring mvc九大组件,比如初始化我们的HandlerMapping(比如requestMappingHandlerMapping 用于处理我们的@RequestMapping)、例化我们的HandlerAdapters
service方法:根据我们的请求调用对应的controller中的方法
从HandlerMapping获取HandlerExecuteChain 处理器执行链:循环所有的HandlerMapping处理器映射器去匹配我们的请求地址,看谁符合(里面会有个HandlerInterceptor拦截器通过路径匹配本次请求是否对应),符合的话就创建对应的执行器链。【为何会有多个处理器映射器呢?比如我们通过@GetMapping注解的控制器跟其他方式的是不同的处理方式的,如果是@GetMapping的会找到RequestMappingHanlderMapping,而这个类的初始化方法又会把我们的@RequestMapping注解信息和方法映射对象保存到我们的路径映射注册表中】而且有一个匹配上了就直接返回执行器链了
在获取到HandlerAdpater后会调用我们拦截器的preHandle方法,然后通过我们的适配器真正的调用我们的目标方法(controller)并且将返回值封装为一个ModelAndView对象,再调用我们拦截器链的postHandle方法
mybatis整合spring源码分析
分析
从案例中我们可以知道mybatis整合到spring中,创建了一个dataSource连接对象,然后使用这个对象创建好sqlSessionFactoryBean(同时指定mybatis配置文件与mapper的xml路径),最后添加@MapperScan(basePackages = {"mapper"})注解。结合单独mybatis的写法,而在这里我们直接从ioc中拿到Mapper对象直接用就可以了,那么spring-mybatis适配器有几个事情需要帮我们做:
- 我们在xml中注册的SqlSessionFactoryBean是怎么创建SqlSessionFactory?
- mybatis是怎么集成到spring事务中去的?
- mapper原为接口又是怎么把代理对象添加到ioc中的?
创建SqlSessionFactory以及集成spring事务源码分析
创建SqlSessionFactory
SqlSessionFactoryBean 是一个FactoryBean,在getBean的时候会直接调用getObject,在此方法中会去解析xml,反正mybatis所有的配置都是保存在一个Configuration中去,一系列解析完后就创建好SqlSessionFactory即可。
集成spring事务
事务管理器是org.springframework.jdbc.datasource包下面的,mybatis的事务是怎么集成到spring中去的呢?
spring在处理事务的时候会从DataSource中获取一个connection放入线程变量中,而且数据库事务是会话级的,
所以我们想要使用spring的事务就得保证我们mybatis执行时的connection跟spring的connection是同一个
spring在处理事务的时候会从DataSource中获取一个connection放入线程变量中,而且数据库事务是会话级的,
所以我们想要使用spring的事务就得保证我们mybatis执行时的connection跟spring的connection是同一个
整合到Spring事务主要在于mybatis在获取连接的时候会去拿spring放在线程变量中的connection。在构建SqlSessionFactory的时候会创建一个spring的事务工厂类SpringManagedTransactionFactory,mybatis会通过这个SpringManagedTransactionFactory获取getConnection(这个connection就是spring事务同步管理器中的connection)
Spring是怎么管理Mapper接口的动态代理的
问题引出
Spring和Mybatis时,我们重点要关注的就是这个代理对象。因为整合的目的就是:把某个Mapper的代理对象作为一个bean放入Spring容器中,使得能够像使用一个普通bean一样去使用这个代理对象,比如能被@Autowire自动注入。如何能够把Mybatis的代理对象作为一个bean放入Spring容器中?
如何能够把Mapper的代理对象作为一个bean放入Spring容器中分析与解决方案
修改BeanDefinition的class类型方案行不通
无法确定我们需要改成哪个类
模拟添加BeanDefinition并通过FactoryBean创建代理对象
那么我们还有没有其他办法,可以去生成bean呢?并且生成bean的逻辑不能由Spring来帮我们做了,得由我们自己来做。有,那就是Spring中的FactoryBean。我们可以利用FactoryBean去自定义我们要生成的bean对象
但是作为程序员,我们不可能每定义了一个Mapper,还得去定义一个MyFactoryBean,这是很麻烦的事情,我们改造一下MyFactoryBean,让他变得更通用
- getObject方法通过jdk动态代理生成代理对象,通过构造器传入需要的接口类型使其变得通用
所以,到此为止,Spring整合Mybatis的核心原理就结束了,再次总结一下:
定义一个MyFactoryBean,用来将Mybatis的代理对象生成一个bean对象【对应spring-mybatis中的MapperFactoryBean】
定义一个MyImportBeanDefinitionRegistrar,用来生成不同Mapper对象的MyFactoryBean【对应spring-mybatis中的MapperScannerRegistrar】
定义一个@MynScan,用来在启动Spring时执行MyImportBeanDefinitionRegistrar的逻辑,并指定mapper包路径【对应spring-mybatis中的@MapperScan】
spring-mybatis如何把Mapper的代理对象作为一个bean放入Spring容器中源码分析
在@MapperScan(basePackages = {"mapper"})中会创建一个MapperScannerRegistrar,解析import的时候会调用MapperScannerRegistrar的registerBeanDefinitions方法,方法中会创建一个MapperScannerConfigurer(是一个带注册器的bean的后置处理器)类型的bean定义,同时会读取到我们@MapperScan注解中指定的mapper路径集合并设置到MapperScannerConfigurer中去。后面在带注册器的bean工厂后置处理器的调用中扫描mapper包并循环生成bean定义,而且这里会将这些mapper的bean定义类型改成FactoryBean(偷天换日,因为spring会跳过接口类型的bean定义),然后通过MapperFactoryBean的构造函数将mapper.class传进去,在getObject方法中会通过SqlSessionFactory.getMapper(""mapper.class)返回我们的代理对象
spring-boot自动装配starter/ˈstɑːtə(r)/原理与源码分析
介绍
在使用spring整合其他框架的时候,我们一般都需要各种配置。比如整合mybatis,我们需要引入spring-mybatis依赖,然后写一个配置类创建DataSource、sqlSessionFactoryBean到ioc中,指定mapper包路径,一堆复杂的配置操作,如果当我们项目大了,需要整合很多框架,那是不是很乱。当然你会想,我可以单独建一个项目,专门用来配置这些整合的配置,然后用每个微服务去依赖这个配置项目即可,的确这么做是可以,而且挺好的。springboot也是类似这种实现的自动装配,只不过它就更加的灵活,它会默认帮你配置,比如默认帮你配置个sqlSessionFactoryBean到ioc中,当然它会使用@ConditionalOnBean 注解先判断我们有没有自己写,如果我们自己写了sqlSessionFactoryBean那么它就不会帮我们创建
SpringBoot 最强大的功能就是把我们常用的场景抽取成了一个个starter(场景启动器),我们通过引入springboot 为我提供的这些场景启动器,我们再进行少量的配置就能使用相应的功能。即使是这样,springboot也不能囊括我们所有的使用场景,往往我们需要自定义starter,来简化我们对springboot的使用
sring-boot会扫描每个jar包META-INF 下的 spring.factories文件,任何一个springboot应用,都会引入spring-boot-autoconfigure,而spring.factories文件就在该包下面,spring.factories为key=value形式,然后配置在里面的value都会自动创建成bean(你自己去写一个key是不会创建bean的),而真正使自动配置生效的key是org.springframework.boot.autoconfigure.EnableAutoConfiguration的配置类,所以spring会帮我们解析
mybatis自动装配的sprin.factories文件:
# Auto Configure
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration
这里有个有意思的,就是springboot不跟mybatis玩,因为springboot有自己的数据库中间件,比如jpa,所以它没有在官方自动装配中有提供,但是mybatis自己写了
# Auto Configure
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration
这里有个有意思的,就是springboot不跟mybatis玩,因为springboot有自己的数据库中间件,比如jpa,所以它没有在官方自动装配中有提供,但是mybatis自己写了
自定义starter案例
我们也可以基于springboot这个机制去自定义我们的starter,总的来说,我们只要有一个项目存在META-INF 下的 spring.factories文件,然后写个自己的AutoConfiguration类即可
spring-boot自动装配原理分析
@SpringBootApplication注解
我们先不去看springboot启动的main方法,反正这个springBootApplication类肯定会成为bean的,所以它会去解析@SpringBootApplication注解,先看下@SpringBootApplication注解
@SpringBootConfiguration
Spring Boot的配置类;标注在某个类上,表示这是一个Spring Boot的配置类;它里面存在@Configuration,所以它可以声明为一个配置类
@EnableAutoConfiguration
开启自动配置功能;以前我们需要配置的东西,Spring-Boot帮我们自动配置;@EnableAutoConfiguration告诉SpringBoot开启自动配置,帮我们自动去加载自动配置类
@AutoConfigurationPackage:将当前配置类所在包保存在BasePackages的Bean中。供Spring内部使用。就是注册了一个保存当前配置类所在包的一个Bean
@Import(AutoConfigurationImportSelector.class) :关键点,会将spring.factories配置的自动配置类通过selector创建成beanDifinition
@ComponentScan
扫描包 相当于在spring.xml 配置中<context:comonent-scan> 但是并没有指定basepackage,如果没有指定spring底层会自动扫描当前配置类所有在的包
以HttpEncodingAutoConfiguration(Http编码自动配置)为例解释自动配置原理
spring.factories文件中会配置HttpEncodingAutoConfiguration自动装配类,类中存在@EnableConfigurationProperties({ServerProperties.class}):将配置文件中对应的值和 ServerProperties绑定起来;并把 ServerProperties加入到 IOC 容器中(这就是读取applicatio.properties配置中我们spring.port的关键)
为什么要用DeferredImportSelector而不使用ImportSelector?
- 首先DeferredImportSelector从字面就能看出是延迟加载的,在spring解析的时候这个会最后解析,也就保证了我自动装配的那些bean是最后加载的,所以在@OnBeanCondition处理的时候就能判断当前是否存在我们自己写的bean,不存在才会帮我们创建
- DeferredImportSelector存在一个Group的内部类,说白了就是可以把当前我们自动装配的bean放到这个Group中,然后再Group中对我们自动装配的bean进行排序,从而不影响到整个spring的顺序
简述spring-boot自动装配过程
在@SpringBootApplication注解的@EnableAutoConfiguration那里引入了@Import(AutoConfigurationImportSelector.class),这个AutoConfigurationImportSelector又是DeferredImportSelector的子类,所以在spring解析@import的时候会调用它的selectImports()方法,spring会将返回的类路径数组生成bean定义。回到这个selectImports()方法,这个方法里面会去获取到springboot启动时候解析到的自动装配配置类(springboot启动的时候通过spring-boot-autoconfigure的META-INF/spring.factories获取到官方配置的EnableAutoConfiguration,放入到cache缓存供我们获取使用),然后这些配置类会生成bean定义,在bean定义解析的时候会通过@ConditionalOnClass来判断是否生效(如果我们引入了xxx-starter的依赖就生效),并且在配置类中的@Bean也可能会通过@ConditionalOnBean判断是否需要帮我们自动装配,如果我们自己写了就用我们自己的,没写就会自动装配bean,这里自动装配的前提又依赖于DeferredImportSelector解析的顺序(我们引入的bean在自动装配的顺序之前)
这些自动装配的bean有什么用?
帮我们整合第三方框架的作用,这样我们可以不去写也有的用。说白了就是帮我们去生成了整合第三方框架所需要的bean
sprin-boot启动原理与源码分析
SpringBoot 是如何通过jar包启动的
得益于SpringBoot的封装,我们可以只通过jar -jar一行命令便启动一个web项目。再也不用操心搭建tomcat等相关web容器。那么,你是否探究过SpringBoot是如
何达到这一操作的呢?只有了解了底层实现原理,才能更好的掌握该项技术带来的好处以及性能调优。本篇文章带大家聊一探究竟
java -jar做了什么
简单来说就是 java -jar 会去找jar中的manifest文件,在那里面找到真正的启动类(manifest文件中有Main-Class的定义,Main-Class的源码中指定了整个应用的启动类);
从上面java-jar命令得知,当我们使用这个命令去启动jar包的时候,java会去找jar包里面META-INF/MANIFEST.MF文件,运行Main-Class指定的那个类中的main方法,但是我们实际看到Main-Class指定的并非是我们的spring-boot启动类,而是org.springframework.boot.loader.JarLauncher,反而看到Start-Class指定的才是我们的spring-boot启动类,所以问题就来了:理论上看,执行java -jar命令时JarLauncher类会被执行,但实际上是Start-Class被执行了,这其中发生了什么呢?为什么要这么做呢?
首先java中是没有提供任何标准的方式来加载嵌套的jar文件(即,它们本身包含在jar中的jar文件),所以我们打好的jar中的jar总是类加载的,那是不是这个JarLauncher就干了这个事情后再通过反射调用start-class指定的真正启动类?答案是的,下面分析
Spring Boot的Jar应用启动流程总结
①:通过maven配置spring-boot-plugin打包工具打包好jar文件,把依赖的jar文件打包在fat jar(jar中含有jar称为fat-jar),在fat jar文件中生成了MANIFEST.MF,MANIFEST.MF又指明了main-class为JarLauncher(指定运行java -jar的主程序),start-class为我们spring-boot的主程序;
②:使用java-jar运行的时候会执行main-class指向的JarLauncher的main方法,方法里面会在加载完BOOT-INF/classes目录及BOOT-INF/lib目录下jar文件后,通过反射调用start-class指定的真正启动类,实现了fat jar的启动。
②:使用java-jar运行的时候会执行main-class指向的JarLauncher的main方法,方法里面会在加载完BOOT-INF/classes目录及BOOT-INF/lib目录下jar文件后,通过反射调用start-class指定的真正启动类,实现了fat jar的启动。
- SpringBoot通过扩展JarFile、JarURLConnection及URLStreamHandler,实现了jar in jar中资源的加载。
- SpringBoot通过扩展URLClassLoader–LauncherURLClassLoader,实现了jar in jar中class文件的加载。
- WarLauncher通过加载WEB-INF/classes目录及WEB-INF/lib和WEB-INF/lib-provided目录下的jar文件,实现了war文件的直接启动及web容器中的启动
SpringBoot是如何启动Spring容器源码
new SpringApplication总结
总的来说就是去初始化了一些信息
- 获取启动类:后续需要根据启动类加载ioc容器
- 获取web应用类型,后续很多地方会用到,比如创建spring上下文
- spring.factories读取了对外扩展的ApplicationContextInitializer ,ApplicationListener 对外扩展, 对类解耦(比如全局配置文件、热部署插件)
- 根据main推算出所在的类,说白了就是获取到启动类
总的来说就是去初始化了一些信息
run(String... args)总结
ps.在这个过程中springboot会调用很多监听器对外进行扩展
- 初始化SpringApplication 从spring.factories 读取 ApplicationListener ApplicationContextInitializer 。
- 运行run方法
- 读取 环境变量 配置信息.....
- 创建springApplication上下文:AnnotationConfigServletWebServerApplicationContext
- 预初始化上下文 : 读取启动类
- 调用refresh 加载ioc容器
- 加载所有的自动配置类
- 创建servlet容器
ps.在这个过程中springboot会调用很多监听器对外进行扩展
spring-boot启动内嵌tomcat源码分析
内嵌tomcat
内嵌tomcat启动方式,就是创建一个tomcat对象,创建tomcat上下文,然后配置servlet进去,接着调用tomcat的start方法即可
springMVC整合spring流程
在spring-mvc.xml文件中配置了:
- ContextLoaderListener:主要是启动了springIoc容器,前面springboot已经启动了所以这个可以脱离不创建;
- DispatcherServlet:主要是拦截我们的请求并作出处理,springboot需要创建
知道了spring-mvc整合spring的关键代码后,所以在我们spring-boot项目里面肯定也启动了一个springIoc容器,以及创建了DispatcherServlet用来拦截处理http请求,知道了这点后,我们看源码就有头绪了。
spring-boot启动内嵌tomcat源码
spring中的onRefresh是个空方法,而spring-boot则重写了它,并且在里面完成了tomcat的启动。
在onRefresh方法里如果当前不存在DispatcherServlet跟webServer则说明为内嵌tomcat,它会去拿到DispatcherServletAutoConfiguration自动装配到的DispatcherServlet对象,并通过ServletContextInitializer将它放入tomcat容器中,启动tomcat
spring-boot启动外置tomcat源码分析
什么是SPI
SPI ,全称为 Service Provider Interface(服务提供者接口),是一种服务发现机制。它通过在ClassPath路径下的META-INF/services文件夹查找文件,自动加载文件里所定义的类
示例:
- 定义一个接口IUserDao
- 创建一个实现类UserDaoImpl
- META-INF/services/下创建文件com.tulingxueyuan.dao.IUserDao,文件名为接口路径,文件内容为具体实现类的全路径
- 测试:使用ServiceLoader.load,它会帮我们去META-INF/services/下找对应的接口名,然后读取里面的文件通过反射创建实例
- ServiceLoader<IUserDao> daos = ServiceLoader.load(IUserDao.class);
那这个有什么用呢?比如在jdbc中我jdbc只需要定义了一个规范接口,依赖什么包就获取什么驱动实例,是mysql还是Oracle,由jar包自己去定义spi文件。
外部Servlet容器启动SpringBoot应用原理
tomcat不会主动去启动springboot应用 ,需要springboot自己去根据servlet特性去做,所以tomcat启动的时候肯定调用了SpringBootServletInitializer的SpringApplicationBuilder , 就会启动springboot
外部Servlet容器启动SpringBoot应用原理: servlet规范中,当servlet容器启动时候 就会去META-INF/services 文件夹中找到javax.servlet.ServletContainerInitializer(这个文件里面肯定绑定一个ServletContainerInitializer),当servlet容器启动时候就会去该文件中找到ServletContainerInitializer的实现类,从而创建它的实例,实现类中使用了@HandlesTypes(WebApplicationInitializer.class)注解会把WebApplicationInitializer类型的class(主要两个:我们自己实现的类SpringBootServletInitializer(它里面传入了启动类,用来找到springboot启动类)、AbstractDispatcherServletInitializer(用来创建DispatcherServlet))传入onstartUp方法参数中再调用,onstartUp方法里面又会循环调用所有的WebApplicationInitializer,AbstractDispatcherServletInitializer主要是创建了DispatcherServlet,我们重写的类主要是去调用了启动了springboot
javax.servlet.ServletContainerInitializer文件内容:
org.springframework.web.SpringServletContainerInitializer
org.springframework.web.SpringServletContainerInitializer
@HandlesTypes(WebApplicationInitializer.class).注解解释:tomcat会找到所有WebApplicationInitializer类型的类(包括之前定义的SpringBootServletInitializer)并且传入onStartup方法中
问题
如何判断环境为servlet还是webflux?
就是判断当前是否存在某个类,比如存在DispatcherServlet说明为servlet环境
其他
一致性hash
普通的Hash函数最大的作用是散列,或者说是将一系列在形式上具有相似性质的数据,打散成随机的、均匀分布的数据。不难发现,这样的Hash只要集群的数量N发生变化,之前的所有Hash映射就会全部失效
固定分配槽位,但是如果其中一台机器挂了,那么这台机器的槽位就没用了,这个可以通过环形结构的hash来解决。
环形hash算法(一致性HASH):固定hash槽位,将各个服务器使用Hash进行一次哈希,具体可以选择服务器的ip或主机名作为关键字进行哈希,这样每台机器就能确定其在哈希环上的位置,将数据key使用相同的函数Hash计算出哈希值,并确定此数据在环上的位置,从此位置沿环顺时针“行走”,第一台遇到的服务器就是其应该定位到的服务器
固定分配槽位,但是如果其中一台机器挂了,那么这台机器的槽位就没用了,这个可以通过环形结构的hash来解决。
环形hash算法(一致性HASH):固定hash槽位,将各个服务器使用Hash进行一次哈希,具体可以选择服务器的ip或主机名作为关键字进行哈希,这样每台机器就能确定其在哈希环上的位置,将数据key使用相同的函数Hash计算出哈希值,并确定此数据在环上的位置,从此位置沿环顺时针“行走”,第一台遇到的服务器就是其应该定位到的服务器
普通hash:如abc三台机器,需要%3定位到节点,但是如果我加一台d,就需要全部重新rehash做数据迁移
分布式CAP理论和BASE理论
什么是CAP?
Consistency (一致性):
“all nodes see the same data at the same time”,即更新操作成功并返回客户端后,所有节点在同一时间的数据完全一致,这就是分布式的一致性。一致性的问题在并发系统中不可避免,对于客户端来说,一致性指的是并发访问时更新过的数据如何获取的问题。从服务端来看,则是更新如何复制分布到整个系统,以保证数据最终一致。
Availability (可用性):
可用性指“Reads and writes always succeed”,即服务一直可用,而且是正常响应时间。好的可用性主要是指系统能够很好的为用户服务,不出现用户操作失败或者访问超时等用户体验不好的情况。
Partition Tolerance (分区容错性):
即分布式系统在遇到某节点或网络分区故障的时候,仍然能够对外提供满足一致性和可用性的服务。分区容错性要求能够使应用虽然是一个分布式系统,而看上去却好像是在一个可以运转正常的整体。比如现在的分布式系统中有某一个或者几个机器宕掉了,其他剩下的机器还能够正常运转满足系统需求,对于用户而言并没有什么体验上的影响。
取舍策略
CAP三个特性只能同时满足其中两个
满足CA的情况下,为何说不能再满足P了?
C为一致性,A为服务的可用性,而且是正常的响应时间,但是此时如果要同时满足P,那么为了满足P会有很多的分区,也就是说在一致性的时候需要等到同步到每个分区后才能进行响应,如果P的分区很多的话那同步时间就会很长也就跟A冲突了
CA without P:如果不要求P(不允许分区),则C(强一致性)和A(可用性)是可以保证的。但放弃P的同时也就意味着放弃了系统的扩展性,也就是分布式节点受限,没办法部署子节点,这是违背分布式系统设计的初衷的。
CP without A:如果不要求A(可用),相当于每个请求都需要在服务器之间保持强一致,而P(分区)会导致同步时间无限延长(也就是等待数据同步完才能正常访问服务),一旦发生网络故障或者消息丢失等情况,就要牺牲用户的体验,等待所有数据全部一致了之后再让用户访问系统。设计成CP的系统其实不少,最典型的就是分布式数据库,如Redis、HBase等。对于这些分布式数据库来说,数据的一致性是最基本的要求,因为如果连这个标准都达不到,那么直接采用关系型数据库就好,没必要再浪费资源来部署分布式数据库。
AP wihtout C:要高可用并允许分区,则需放弃一致性。一旦分区发生,节点之间可能会失去联系,为了高可用,每个节点只能用本地数据提供服务,而这样会导致全局数据的不一致性。典型的应用就如某米的抢购手机场景,可能前几秒你浏览商品的时候页面提示是有库存的,当你选择完商品准备下单的时候,系统提示你下单失败,商品已售完。这其实就是先在 A(可用性)方面保证系统可以正常的服务,然后在数据的一致性方面做了些牺牲,虽然多少会影响一些用户体验,但也不至于造成用户购物流程的严重阻塞
Base理论
BASE是Basically Available(基本可用)、Soft state(软状态)和Eventually consistent(最终一致性)三个短语的缩写。BASE理论是对CAP中一致性和可用性权衡的结果,其来源于对大规模互联网系统分布式实践的总结, 是基于CAP定理逐步演化而来的。BASE理论的核心思想是:即使无法做到强一致性,但每个应用都可以根据自身业务特点,采用适当的方式来使系统达到最终一致性。
BASE中的三要素
基本可用:基本可用是指分布式系统在出现不可预知故障的时候,允许损失部分可用性—-注意,这绝不等价于系统不可用。比如:
(1)响应时间上的损失。正常情况下,一个在线搜索引擎需要在0.5秒之内返回给用户相应的查询结果,但由于出现故障,查询结果的响应时间增加了1~2秒
(2)系统功能上的损失:正常情况下,在一个电子商务网站上进行购物的时候,消费者几乎能够顺利完成每一笔订单,但是在一些节日大促购物高峰的时候,由于消费者的购物行为激增,为了保护购物系统的稳定性,部分消费者可能会被引导到一个降级页面
(1)响应时间上的损失。正常情况下,一个在线搜索引擎需要在0.5秒之内返回给用户相应的查询结果,但由于出现故障,查询结果的响应时间增加了1~2秒
(2)系统功能上的损失:正常情况下,在一个电子商务网站上进行购物的时候,消费者几乎能够顺利完成每一笔订单,但是在一些节日大促购物高峰的时候,由于消费者的购物行为激增,为了保护购物系统的稳定性,部分消费者可能会被引导到一个降级页面
软状态:软状态指允许系统中的数据存在中间状态,并认为该中间状态的存在不会影响系统的整体可用性,即允许系统在不同节点的数据副本之间进行数据同步的过程存在延时
最终一致性:最终一致性强调的是所有的数据副本,在经过一段时间的同步之后,最终都能够达到一个一致的状态。因此,最终一致性的本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性。总的来说,BASE理论面向的是大型高可用可扩展的分布式系统,和传统的事物ACID特性是相反的,它完全不同于ACID的强一致性模型,而是通过牺牲强一致性来获得可用性,并允许数据在一段时间内是不一致的,但最终达到一致状态。但同时,在实际的分布式场景中,不同业务单元和组件对数据一致性的要求是不同的,因此在具体的分布式系统架构设计过程中,ACID特性和BASE理论往往又会结合在一起
常见产品
Eureka:eureka是SpringCloud系列用来做服务注册和发现的组件,作为服务发现的一个实现,在设计的时候就更考虑了可用性,保证了AP。
Zookeeper:Zookeeper在实现上牺牲了可用性,保证了一致性(单调一致性)和分区容错性,也即:CP。所以这也是SpringCloud抛弃了zookeeper而选择Eureka的原因。
Zookeeper:Zookeeper在实现上牺牲了可用性,保证了一致性(单调一致性)和分区容错性,也即:CP。所以这也是SpringCloud抛弃了zookeeper而选择Eureka的原因。
分布式事务
事务及其ACID属性
- 原子性(Atomicity) :原子性操作可以理解为把一个事务中个多个操作看成为一个操作一样,只存在全部成功跟全部失败的可能。
- 一致性(Consistent) :原子性站在操作层面(多个操作看成一个操作),一致性为站在数据的层面上--事务的各个操作都修改数据成功,也就是数据从一个状态到另一个状态必须是一致的,比如A向B转账(A、B的总金额就是一个一致性状态),不可能出现A扣了钱,B却没收到的情况发生
- 隔离性(Isolation):数据库系统提供一定的隔离机制,保证事务在不受外部并发操作影响的“独立”环境执行。这意味着事务处理过程中的中间状态对外部是不可见的,反之亦然,数据的隔离性是针对并发事务而言,非并发事务是天然隔离的。并发事务是指两个事务操作了同一份数据的情况;
- 持久性(Durable) :事务完成之后,它对于数据的修改是永久性的,即使出现系统故障也能够保持
关于事务与隔离级别的理解
“脏读”、“不可重复读”和“幻读”,其实都是数据库读一致性问题,也就是没有满足事务的隔离性而带来的问题,可以通过数据库提供的事务隔离机制来解决隔离性。也就是说数据库的隔离级别正是为了解决并发事务带来的事务隔离性问题,这些问题根本原因在于并发,并发事务也就是并发,而解决并发最好的方式就是锁,事务的隔离机制也是在一定程度上的“串行化”,像可串行化这种隔离级别就是通过锁把并发串行化了,也就不存在并发事务所带来的问题了,可以完全满足事务隔离性。
并发所带来的问题也还是需要根据业务场景作出不同取舍处理,当然想完全解决隔离性那使用可串行化隔离级别即可,但是并不是所有的业务都需要这么高,有的业务场景是可以容忍的,隔离级别越高并发也就越少。然后就是数据库中的锁,什么行锁、表锁、悲观锁、乐观锁、间隙锁啥的,都只是在解决并发问题,比如在可重复读这种隔离级别的时候,select是不会加锁的,也就是乐观锁,而在可串行化隔离级别下select是会加锁的,属于悲观锁(悲观的认为我在读的时候别人会来改数据,而我可能因为读得比你写的慢)。这些概念不要搞晕了。
所以说你想完全满足事务的ACID那是不可能的,这都只是理想状态
并发所带来的问题也还是需要根据业务场景作出不同取舍处理,当然想完全解决隔离性那使用可串行化隔离级别即可,但是并不是所有的业务都需要这么高,有的业务场景是可以容忍的,隔离级别越高并发也就越少。然后就是数据库中的锁,什么行锁、表锁、悲观锁、乐观锁、间隙锁啥的,都只是在解决并发问题,比如在可重复读这种隔离级别的时候,select是不会加锁的,也就是乐观锁,而在可串行化隔离级别下select是会加锁的,属于悲观锁(悲观的认为我在读的时候别人会来改数据,而我可能因为读得比你写的慢)。这些概念不要搞晕了。
所以说你想完全满足事务的ACID那是不可能的,这都只是理想状态
分布式事务
分布式事务四种模式
XA模式
XA是X/Open DTP组织(X/Open DTP group)定义的两阶段提交协议,XA被许多数据库(如Oracle、DB2、SQL Server、MySQL)和中间件等工具(如CICS 和 Tuxedo)本地支持 。
X/Open DTP模型(1994)包括应用程序(AP)、事务管理器(TM)、资源管理器(RM)
XA接口函数由数据库厂商提供。XA规范的基础是两阶段提交协议2PC。JTA(Java Transaction API) 是Java实现的XA规范的增强版接口
在XA模式下,需要有一个[全局]协调器,每一个数据库事务完成后,进行第一阶段预提交,并通知协调器,把结果给协调器。协调器等所有分支事务操作完成、都预提交后,进行第二步;第二步:协调器通知每个数据库进行逐个commit/rollback.其中,这个全局协调器就是XA模型中的TM角色,每个分支事务各自的数据库就是RM
XA模式下的 开源框架有atomikos(TransactionEssentials:开源的免费产品、ExtremeTransactions:商业版,需要收费)。
XA模式缺点:事务粒度大。高并发下,系统可用性低。因此很少使用
TCC 模式(两阶段补偿方案)
TCC 模式需要用户根据自己的业务场景实现 Try、Confirm 和 Cancel 三个操作;事务发起方在一阶段执行 Try 方式,在二阶段提交执行 Confirm 方法,二阶段回滚执行 Cancel 方法。
TCC 三个方法描述:
TCC 三个方法描述:
- Try:资源的检测和预留;
- Confirm:执行的业务操作提交;要求 Try 成功 Confirm 一定要能成功;
- Cancel:预留资源释放
转账案例:比如A给B转账,A、B账户在不同的服务且不同的库。
try操作:
confirm操作:当两个try操作都成功后,调用两个confirm操作。
cancel操作:当其中一个try操作失败后,调用两个cancel操作。
try操作:
- A-try操作:A账户余额减一,冻结金额加一;
- B-try操作:B账户余额不变,冻结金额加一;
confirm操作:当两个try操作都成功后,调用两个confirm操作。
- A-confirm操作:将A账户的冻结金额设置为0,
- B-confirm操作:将B账户的冻结金额加到余额中去,
- 注意这里就算一方加失败了也不会回滚另外一方,失败的那方会不断重试直到成功为止
cancel操作:当其中一个try操作失败后,调用两个cancel操作。
- A-cancel操作:将A账户的冻结金额加回去,
- B-cancel操作:将B账户的冻结金额减回去,
- 跟confirm一样,一方失败不会回滚另外一方,也是一直重试到成功
总结:这里每次操作都是直接commit,并不会造成资源锁定,所以并发相对会高的多。但是因为跟业务耦合性太高以及实现难度,真正用的不多,对业务代码侵入性太高,需要把每个业务操作拆分成三个操作,也就是以前一个方法,现在需要try、confirm、cancel三个方法
TCC 的实践经验
蚂蚁金服TCC实践,总结以下注意事项:
➢业务模型分2阶段设计:分为 try、confirm/cancel 两个阶段
➢并发控制
➢允许空回滚:Cancel 接口设计时需要允许空回滚。在 Try 接口因为丢包时没有收到,事务管理器会触发回滚,这时会触发 Cancel 接口,这时 Cancel 执行时发现没有对应的事务 xid 或主键时,需要返回回滚成功。让事务服务管理器认为已回滚,否则会不断重试,而 Cancel 又没有对应的业务数据可以进行回滚。
➢防悬挂控制:Cancel 比 Try 接口先执行,出现的原因是 Try 由于网络拥堵而超时,事务管理器生成回滚,触发 Cancel 接口,而最终又收到了 Try 接口调用,但是 Cancel 比 Try 先到。按照前面允许空回滚的逻辑,回滚会返回成功,事务管理器认为事务已回滚成功,则此时的 Try 接口不应该执行,否则会产生数据不一致,所以我们在 Cancel 空回滚返回成功之前先记录该条事务 xid 或业务主键,标识这条记录已经回滚过,Try 接口先检查这条事务xid或业务主键如果已经标记为回滚成功过,则不执行 Try 的业务操作。
➢幂等控制:对同一个系统,使用同样的条件,一次请求和重复的多次请求对系统资源的影响是一致的。因为网络抖动或拥堵可能会超时,事务管理器会对资源进行重试操作,所以很可能一个业务操作会被重复调用,为了不因为重复调用而多次占用资源,需要对服务设计时进行幂等控制,通常我们可以用事务 xid 或业务主键判重来控制
TCC与XA/JTA对比
- XA是资源层面的两阶段分布式事务,强一致性,在两阶段提交的整个过程中,一直会持有资源的锁
- TCC是业务层面的两阶段分布式事务,最终一致性,不会一直持有资源的锁
TCC的开源框架实现:Atomikos(收费版本中才有),tcc-transaction,ByteTcc,支付宝GTS(这个就是Seata的商用版本)
AT模式
AT 模式是阿里提出来的一种无侵入的分布式事务解决方案。在 AT 模式下,用户只需关注自己的“业务 SQL”,用户的 “业务 SQL” 作为一阶段,Seata 框架会自动生成事务的二阶段提交和回滚操作
一阶段:
在一阶段,Seata 会拦截“业务 SQL”,首先解析 SQL 语义,找到“业务 SQL”要更新的业务数据,在业务数据被更新前,将其保存成“before image”,然后执行“业务 SQL”更新业务数据,在业务数据更新之后,再将其保存成“after image”,最后生成行锁。以上操作全部在一个数据库事务内完成,这样保证了一阶段操作的原子性
二阶段提交:
二阶段如果是提交的话,因为“业务 SQL”在一阶段已经提交至数据库, 所以 Seata 框架只需将一阶段保存的快照数据和行锁删掉,完成数据清理即可。
二阶段回滚:
二阶段如果是回滚的话,Seata 就需要回滚一阶段已经执行的“业务 SQL”,还原业务数据。回滚方式便是用“before image”还原业务数据;但在还原前要首先要校验脏写,对比“数据库当前业务数据”和 “after image”,如果两份数据完全一致就说明没有脏写,可以还原业务数据,如果不一致就说明有脏写,出现脏写就需要转人工处理。
在一阶段,Seata 会拦截“业务 SQL”,首先解析 SQL 语义,找到“业务 SQL”要更新的业务数据,在业务数据被更新前,将其保存成“before image”,然后执行“业务 SQL”更新业务数据,在业务数据更新之后,再将其保存成“after image”,最后生成行锁。以上操作全部在一个数据库事务内完成,这样保证了一阶段操作的原子性
二阶段提交:
二阶段如果是提交的话,因为“业务 SQL”在一阶段已经提交至数据库, 所以 Seata 框架只需将一阶段保存的快照数据和行锁删掉,完成数据清理即可。
二阶段回滚:
二阶段如果是回滚的话,Seata 就需要回滚一阶段已经执行的“业务 SQL”,还原业务数据。回滚方式便是用“before image”还原业务数据;但在还原前要首先要校验脏写,对比“数据库当前业务数据”和 “after image”,如果两份数据完全一致就说明没有脏写,可以还原业务数据,如果不一致就说明有脏写,出现脏写就需要转人工处理。
AT 模式的一阶段、二阶段提交/回滚均由框架自动生成,用户只需编写“业务 SQL”,便能轻松接入分布式事务,AT 模式是一种对业务无任何侵入的分布式事务解决方案。
saga模式
分布式事务执行过程中,依次执行各参与者的正向操作,如果所有正向操作均执行成功,那么分布式事务提交。如果任何一个正向操作执行失败,那么分布式事务会退回去执行前面各参与者的逆向回滚操作,回滚已提交的参与者,使分布式事务回到初始状态。大概写个案例帮助理解:假设三个分支事务t1、t2、t3
t1正向服务.....
try{
t2正向服务.....
}catch (Exception e){
t1补偿服务.....
}
try{
t3正向服务.....
}catch (Exception e){
t1补偿服务.....
t2补偿服务.....
}
t1正向服务.....
try{
t2正向服务.....
}catch (Exception e){
t1补偿服务.....
}
try{
t3正向服务.....
}catch (Exception e){
t1补偿服务.....
t2补偿服务.....
}
Saga 正向服务与补偿服务也需要业务开发者实现。因此是业务入侵的。Saga 模式下分布式事务通常是由事件驱动的,Saga 模式是一种长事务解决方案
Saga 模式使用场景
Saga 模式适用于业务流程长且需要保证事务最终一致性的业务系统,Saga 模式一阶段就会提交本地事务,无锁、长流程情况下可以保证性能
事务参与者可能是其它公司的服务或者是遗留系统的服务,无法进行改造和提供 TCC 要求的接口,可以使用 Saga 模式
Saga 模式适用于业务流程长且需要保证事务最终一致性的业务系统,Saga 模式一阶段就会提交本地事务,无锁、长流程情况下可以保证性能
事务参与者可能是其它公司的服务或者是遗留系统的服务,无法进行改造和提供 TCC 要求的接口,可以使用 Saga 模式
总结
四种分布式事务模式,分别在不同的时间被提出,每种模式都有它的适用场景。四种分布式事务模式,都有各自的理论基础,分别在不同的时间被提出;每种模式都有它的适用场景,同样每个模式也都诞生有各自的代表产品;而这些代表产品,可能就是我们常见的(全局事务、基于可靠消息、最大努力通知、TCC)。而且他们有一个共同点,都是“两阶段”
AT 模式是无侵入的分布式事务解决方案,适用于不希望对业务进行改造的场景,几乎0学习成本。
TCC 模式是高性能分布式事务解决方案,适用于核心系统等对性能有很高要求的场景。
Saga 模式是长事务解决方案,适用于业务流程长且需要保证事务最终一致性的业务系统,Saga 模式一阶段就会提交本地事务,无锁,长流程情况下可以保证性能,多用于渠道层、集成层业务系统。事务参与者可能是其它公司的服务或者是遗留系统的服务,无法进行改造和提供 TCC 要求的接口,也可以使用 Saga 模式。
XA模式是分布式强一致性的解决方案,但性能低而使用较少。
很多解决方案往往不只是模式定义的规范那样,可能还会加上一些额外的处理来提高准确性,也有一些分布式解决方案不属于这里的某种,可能类似,所以我们不能受限于这几种模式,主要还是根据不同的业务场景选择不同的方案,
AT 模式是无侵入的分布式事务解决方案,适用于不希望对业务进行改造的场景,几乎0学习成本。
TCC 模式是高性能分布式事务解决方案,适用于核心系统等对性能有很高要求的场景。
Saga 模式是长事务解决方案,适用于业务流程长且需要保证事务最终一致性的业务系统,Saga 模式一阶段就会提交本地事务,无锁,长流程情况下可以保证性能,多用于渠道层、集成层业务系统。事务参与者可能是其它公司的服务或者是遗留系统的服务,无法进行改造和提供 TCC 要求的接口,也可以使用 Saga 模式。
XA模式是分布式强一致性的解决方案,但性能低而使用较少。
很多解决方案往往不只是模式定义的规范那样,可能还会加上一些额外的处理来提高准确性,也有一些分布式解决方案不属于这里的某种,可能类似,所以我们不能受限于这几种模式,主要还是根据不同的业务场景选择不同的方案,
分布式解决方案
XA中的2PC
2pc解决方案
2PC(Two-phase commit protocol),中文叫二阶段提交。 二阶段提交是一种强一致性设计(提问:为什么说2pc是强一致性呢,它不是一样会存在数据不一致的可能性吗?),2PC 引入一个事务协调者的角色来协调管理各参与者(也可称之为各本地资源)的提交和回滚,二阶段分别指的是准备(投票)和提交两个阶段
关于为什么2pc属于强一致性可以这么理解:首先这个一致性是站在CAP中的C中说的。它在整个阶段为结束前资源都是锁定的状态,像基于消息队列实现的解决方案中,事务结束后是存在数据的中间状态的,但是经过一段时间后数据达到最终一致性,而2pc是不存在这个时间差的(这是关键)。而且关于一致性有这个一个特性:站在用户层面上,如果执行成功了,那么它在各个分布式节点都是能够立马被感知到的,当然执行失败也可以感知到。回过头来看非极端情况下2pc是不是也满足,综上其实就可以把2pc说成是强一致性(因为它也只是尽量去保证强一致性)
1、准备阶段
准备阶段协调者会给各参与者发送准备命令,你可以把准备命令理解成除了提交事务之外啥事都做完了。
2、提交/回滚阶段
同步等待所有资源的响应之后就进入第二阶段即提交阶段(注意提交阶段不一定是提交事务,也可能是回滚事务)
准备阶段协调者会给各参与者发送准备命令,你可以把准备命令理解成除了提交事务之外啥事都做完了。
2、提交/回滚阶段
同步等待所有资源的响应之后就进入第二阶段即提交阶段(注意提交阶段不一定是提交事务,也可能是回滚事务)
- 提交:假如在第一阶段所有参与者都返回准备成功,那么协调者则向所有参与者发送提交事务命令,然后等待所有事务都提交成功之后,返回事务执行成功。
- 回滚:假如在第一阶段有一个参与者返回失败,那么协调者就会向所有参与者发送回滚事务的请求,即分布式事务执行失败。
单点故障与失败分析
那可能就有人问了,那第二阶段提交失败的话呢?这里有两种情况。
第一种是第二阶段执行的是回滚事务操作,那么答案是不断重试,直到所有参与者都回滚了,不然那些在第一阶段准备成功的参与者会一直阻塞着。
第二种是第二阶段执行的是提交事务操作,那么答案也是不断重试,因为有可能一些参与者的事务已经提交成功了,这个时候只有一条路,就是头铁往前冲,不断的重试,直到提交成功,到最后真的不行只能人工介入处理。
大体上二阶段提交的流程就是这样,我们再来看看细节。
首先 2PC 是一个同步阻塞协议,像第一阶段协调者会等待所有参与者响应才会进行下一步操作,当然第一阶段的协调者有超时机制,假设因为网络原因没有收到某参与者的响应或某参与者挂了,那么超时后就会判断事务失败,向所有参与者发送回滚命令。
在第二阶段协调者的没法超时,因为按照我们上面分析只能不断重试!
单点故障指的是协调者为一个单点,如果协调者挂了就会出现问题,可以根据挂的时机来进行分析可能存在的问题以及针对方案。单点故障问题很难解决也没发完全解决,就算你有选举机制
总结
2PC 是一种尽量保证强一致性的分布式事务,因此它是同步阻塞的,而同步阻塞就导致长久的资源锁定问题,总体而言效率低,并且存在单点故障问题,在极端条件下存在数据不一致的风险
XA中的3PC
3pc解决方案
3PC 的出现是为了解决 2PC 的一些问题,相比于 2PC 它在参与者中也引入了超时机制,并且新增了一个阶段使得参与者可以利用这一个阶段统一各自的状态。
3PC 包含了三个阶段,分别是准备阶段、预提交阶段和提交阶段,对应的英文就是:CanCommit、PreCommit 和 DoCommit。
看起来是把 2PC 的提交阶段变成了预提交阶段和提交阶段,但是 3PC 的准备阶段协调者只是询问参与者的自身状况,比如你现在还好吗?负载重不重?这类的。而预提交阶段就是和 2PC 的准备阶段一样,除了事务的提交该做的都做了。
3PC 包含了三个阶段,分别是准备阶段、预提交阶段和提交阶段,对应的英文就是:CanCommit、PreCommit 和 DoCommit。
看起来是把 2PC 的提交阶段变成了预提交阶段和提交阶段,但是 3PC 的准备阶段协调者只是询问参与者的自身状况,比如你现在还好吗?负载重不重?这类的。而预提交阶段就是和 2PC 的准备阶段一样,除了事务的提交该做的都做了。
不管哪一个阶段有参与者返回失败都会宣布事务失败,这和 2PC 是一样的(当然到最后的提交阶段和 2PC 一样只要是提交请求就只能不断重试)
3PC 的阶段变更相对2pc有什么影响
首先准备阶段的变更成不会直接执行事务,而是会先去询问此时的参与者是否有条件接这个事务(至于这个具体做了什么事,我是感觉可以理解为:服务是否可用、数据库是否可用tenet一下、数据资源是否够比如余额是否充足之类的),因此不会一来就干活直接锁资源,使得在某些资源不可用的情况下所有参与者都阻塞着。
但是多引入一个阶段也多一个交互,因此性能会差一些,而且绝大部分的情况下资源应该都是可用的,这样等于每次明知可用执行还得询问一次
但是多引入一个阶段也多一个交互,因此性能会差一些,而且绝大部分的情况下资源应该都是可用的,这样等于每次明知可用执行还得询问一次
从维基百科上看,3PC 的引入是为了解决提交阶段 2PC 协调者和某参与者都挂了之后新选举的协调者不知道当前应该提交还是回滚的问题。
新协调者来的时候发现有一个参与者处于预提交或者提交阶段,那么表明已经经过了所有参与者的确认了(这里说的是CanCommit第一阶段的确认结果),所以此时执行的就是提交命令(其实也未必就是提交啦,只是大部分都是提交,因为也有可能第一阶段成功,预提交阶段失败啊)但是这也只能让协调者知道该如果做,但不能保证这样做一定对,这其实和上面 2PC 分析一致,因为挂了的参与者到底有没有执行事务无法断定
新协调者来的时候发现有一个参与者处于预提交或者提交阶段,那么表明已经经过了所有参与者的确认了(这里说的是CanCommit第一阶段的确认结果),所以此时执行的就是提交命令(其实也未必就是提交啦,只是大部分都是提交,因为也有可能第一阶段成功,预提交阶段失败啊)但是这也只能让协调者知道该如果做,但不能保证这样做一定对,这其实和上面 2PC 分析一致,因为挂了的参与者到底有没有执行事务无法断定
总结
让我们总结一下, 3PC 相对于 2PC 做了一定的改进:引入了参与者超时机制(当然在具体实现2pc的时候往往也会在参与者加入超时机制,只是XA规范没有而已),并且增加了预提交阶段使得故障恢复之后协调者的决策复杂度降低,但整体的交互过程更长了,性能有所下降,并且还是会存在数据不一致问题。
所以 2PC 和 3PC 都不能保证数据100%一致,因此一般都需要有定时扫描补偿机制。
我再说下 3PC 我没有找到具体的实现,所以我认为 3PC 只是纯的理论上的东西,而且可以看到相比于 2PC 它是做了一些努力但是效果甚微,所以只做了解即可
所以 2PC 和 3PC 都不能保证数据100%一致,因此一般都需要有定时扫描补偿机制。
我再说下 3PC 我没有找到具体的实现,所以我认为 3PC 只是纯的理论上的东西,而且可以看到相比于 2PC 它是做了一些努力但是效果甚微,所以只做了解即可
柔性事务方案
柔性事务--TCC
这里就简单提下,上面分析分布式事务模式的时候就讲的很清楚了。所谓柔性事务指的是基于Base理论而来,刚性事务则是强一致而言
2PC 和 3PC 都是数据库层面的,而 TCC 是业务层面的分布式事务
柔性事务--本地消息表
本地消息表顾名思义就是会有一张存放本地消息的表,一般都是放在数据库中,然后在执行业务的时候 将业务的执行和将消息放入消息表中的操作放在同一个事务中,这样就能保证消息放入本地表中业务肯定是执行成功的。
然后再去调用下一个操作,如果下一个操作调用成功了好说,消息表的消息状态可以直接改成已成功。
如果调用失败也没事,会有 后台任务定时去读取本地消息表,筛选出还未成功的消息再调用对应的服务,服务更新成功了再变更消息的状态。
这时候有可能消息对应的操作不成功,因此也需要重试,重试就得保证对应服务的方法是幂等的,而且一般重试会有最大次数,超过最大次数可以记录下报警让人工处理。
可以看到本地消息表其实实现的是最终一致性,容忍了数据暂时不一致的情况
然后再去调用下一个操作,如果下一个操作调用成功了好说,消息表的消息状态可以直接改成已成功。
如果调用失败也没事,会有 后台任务定时去读取本地消息表,筛选出还未成功的消息再调用对应的服务,服务更新成功了再变更消息的状态。
这时候有可能消息对应的操作不成功,因此也需要重试,重试就得保证对应服务的方法是幂等的,而且一般重试会有最大次数,超过最大次数可以记录下报警让人工处理。
可以看到本地消息表其实实现的是最终一致性,容忍了数据暂时不一致的情况
柔性事务--可靠消息最终一致性方案
RocketMQ 就很好的支持了消息事务,这里就以rocketmq的事务消息来说基于可靠消息最终一致性方案:
事务消息发送步骤如下:
1、发送方将半事务消息发送至消息队列 MQ 服务端。
2、消息队列 MQ 服务端将消息持久化成功之后,向发送方返回 Ack 确认消息已经发送成功,此时消息为半事务消息。
3、发送方开始执行本地事务逻辑。
4、发送方根据本地事务执行结果向服务端提交二次确认(Commit 或是 Rollback),服务端收到 Commit 状态则将半事务消息标记为可投递,订阅方最终将收到该消息;服务端收到Rollback 状态则删除半事务消息,订阅方将不会接受该消息。
事务消息回查步骤如下:
1、在断网或者是应用重启的特殊情况下,上述步骤 4 提交的二次确认最终未到达服务端,经过固定时间后服务端将对该消息发起消息回查。
2、发送方收到消息回查后,需要检查对应消息的本地事务执行的最终结果。
3、发送方根据检查得到的本地事务的最终状态再次提交二次确认,服务端仍按照步骤 4对半事务消息进行操作。
可以看到消息事务实现的也是最终一致性。当然其他的消息中间件虽然没有提供事务消息,但是也是能通过消息特点实现分布式事务
事务消息发送步骤如下:
1、发送方将半事务消息发送至消息队列 MQ 服务端。
2、消息队列 MQ 服务端将消息持久化成功之后,向发送方返回 Ack 确认消息已经发送成功,此时消息为半事务消息。
3、发送方开始执行本地事务逻辑。
4、发送方根据本地事务执行结果向服务端提交二次确认(Commit 或是 Rollback),服务端收到 Commit 状态则将半事务消息标记为可投递,订阅方最终将收到该消息;服务端收到Rollback 状态则删除半事务消息,订阅方将不会接受该消息。
事务消息回查步骤如下:
1、在断网或者是应用重启的特殊情况下,上述步骤 4 提交的二次确认最终未到达服务端,经过固定时间后服务端将对该消息发起消息回查。
2、发送方收到消息回查后,需要检查对应消息的本地事务执行的最终结果。
3、发送方根据检查得到的本地事务的最终状态再次提交二次确认,服务端仍按照步骤 4对半事务消息进行操作。
可以看到消息事务实现的也是最终一致性。当然其他的消息中间件虽然没有提供事务消息,但是也是能通过消息特点实现分布式事务
最大努力通知
最简单的一种柔性事务,适用于一些最终一致性时间敏感度低的业务,且被动方处理结果不影响主动方的处理结果。典型的使用场景:如银行通知、商户通知等
其实我觉得本地消息表也可以算最大努力,事务消息也可以算最大努力。
就本地消息表来说会有后台任务定时去查看未完成的消息,然后去调用对应的服务,当一个消息多次调用都失败的时候可以记录下然后引入人工,或者直接舍弃。这其实算是最大努力了。
事务消息也是一样,当半消息被commit了之后确实就是普通消息了,如果订阅者一直不消费或者消费不了则会一直重试,到最后进入死信队列。其实这也算最大努力。
所以最大努力通知其实只是表明了一种柔性事务的思想:我已经尽力我最大的努力想达成事务的最终一致了。
适用于对时间不敏感的业务,例如短信通知
就本地消息表来说会有后台任务定时去查看未完成的消息,然后去调用对应的服务,当一个消息多次调用都失败的时候可以记录下然后引入人工,或者直接舍弃。这其实算是最大努力了。
事务消息也是一样,当半消息被commit了之后确实就是普通消息了,如果订阅者一直不消费或者消费不了则会一直重试,到最后进入死信队列。其实这也算最大努力。
所以最大努力通知其实只是表明了一种柔性事务的思想:我已经尽力我最大的努力想达成事务的最终一致了。
适用于对时间不敏感的业务,例如短信通知
总结
上面说的分布式事务存在4种模式:XA、AT、TCC、Saga,但是呢后面说的分布式实现方案中有的又不属于4种模式中的一种,所以说呢,我们不能受限于4中模式,这4种模式只是别人提出来的,把重要思想给我们总结好了,给我们排坑,而且呢在具体实现中又不会完全去遵守他们说的,很多时候会加东西来提供性能或者可行性
当然前面说到了分布式理论中的CAP跟Base理论,为什么要在这里说到呢?不管是分布式系统还是分布式事务,只要是分布式的东西,其实我觉得都是可以通过CAP或者Base理论来深入理解的,比如上面说的TCC方案,严格说虽然只涉及到了CAP中的一致性、可用性,至于分区容错性其实没有涉及,因为就事务而言能有多少服务?但是TCC是可以根据Base理论来理解最终一致性的
另外再说一个点:针对同一个数据库的分布式事务,我们是否可以这样来做---我弄一个数据库执行者,所有操作数据库的语句都由它来帮我们做,然后其他分支服务需要把sql语句给TCP给发送过来,因为所有的sql语句都是由数据库执行来执行,那么不就可以通过本地事务来控制了吗?想想确实是很有可行性啊,但是如果说有一台服务的sql语句一直发送不过来或者发送失败怎么办?数据库执行者怎么知道总共有多少语句需要执行?最重要的是你这多个服务发送sql语句的操作不也是分布式事务吗,它一样也是需要满足ACID的啊,哈哈哈,所以你这只是把它换成另外一种分布式事务了而已,最终还是不提议这样做
阿里的Seata 将为用户提供了 AT、TCC、SAGA 和 XA 事务模式,只不过有些模式只有在商业版本中才支持,为用户打造一站式的分布式解决方案(AT模式是阿里首推的模式,阿里云上有商用版本的GTS[Global Transaction service 全局事务服务] )
servlet过滤器与拦截器
首先他们都需要配置拦截规则
Filter是依赖于Servlet容器,属于Servlet规范的一部分,而拦截器则是独立存在的,可以在任何情况下使用。
Filter的执行由Servlet容器回调完成,而拦截器通常通过动态代理的方式来执行。
Filter的生命周期由Servlet容器管理,而拦截器则可以通过IOC容器来管理,因此可以通过注入等方式来获取其他Bean的实例,因此使用会更方便
Filter是依赖于Servlet容器,属于Servlet规范的一部分,而拦截器则是独立存在的,可以在任何情况下使用。
Filter的执行由Servlet容器回调完成,而拦截器通常通过动态代理的方式来执行。
Filter的生命周期由Servlet容器管理,而拦截器则可以通过IOC容器来管理,因此可以通过注入等方式来获取其他Bean的实例,因此使用会更方便
spring session以及JWT
拦截器实现权限认证与spring session
使用拦截器实现登录拦截以及白名单
实现原理:用户第一次请求到我们的服务器时,就会自动生成一个sessionId,接着用户第二次请求的时候就会带入这个sessionId。我们就可以利用这个机制,在登录的时候将我们的userInfo(需要脱敏)放入session中,tomcat会将session保存在我们的内存中,用户第二次请求到服务器的时候,我们可以通过session对象获取到我们的userInfo(tomcat会根据sessionId找到我们需要的session对象也就是request.getSession()方法)
分布式中存在的问题
刚才我们说了,我们的userInfo是存在session中,而session是存在内存中,这就存在分布式session的问题了。如下图架构,比如用户第一次登录的时候,ng负载到了web服务1,此时web1有了session中有了我们的登录信息,但是第二次请求的时候如果ng将请求打到了web2,此时我们的web2是没有用户信息的,这就出错了,当然如果说nginx使用对IP进行hash负载呢?那不就一直打到同一台服务器了,对,这样是没问题,但是请求不均匀而且也不是我们这里探讨的
什么是spring session
Spring Session提供了一种API和实现,用于管理用户的会话信息,同时使其轻松地支持集群会话,而不必依赖于特定于应用程序容器的解决方案。它还提供以下方面的透明集成(无侵入的整合):
- HttpSession:允许以与HttpSession应用程序容器无关的方式替换,并支持在标头中提供会话ID以与RESTful API一起使用。
- WebSocket:提供HttpSession接收WebSocket消息时保持活动的功能
- WebSession:允许以与WebSession应用程序容器无关的方式替换Spring WebFlux 。
JSON Web Token(JWT)
介绍
全称JSON Web Token,用户会话信息存储在客户端浏览器,它定义了一种紧凑且自包含的方式,用于在各方之间以JSON对象进行安全传输信息。这些信息可以通过对称/非对称方式进行签名,防止信息被串改。因为存在数字签名,因此所传递的信息是安全的。由此可知JWT是:
- 是JSON格式数据
- 是一个Token,也就是一个令牌方式
jwt三部分数据
JWT数据包含三部分:【Header.Payload.Signature】
- Header头部:指定JWT使用的签名算法
- Payload有效载荷:是 JWT 的主体,同样也是个 JSON 对象
- Signature签名:主要是确保数据不会被篡改。它主要是对前面所讲的两个部分进行签名,通过 JWT 头定义的算法生成哈希
JWT工作方式
通常我们把token设置在request‐Header头中,每次请求前都在请求头加上配置 Authorization: Bearer <token>
示例:
Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0IiwiY3JlYXRlZCI6MTU3NzE3NjE0OTQ3OCwiZXhwIjoxNTc3NzgwOTQ5fQ.qSlhJNpom2XeeqMyXST2AdHvAjztWqR4zvQQEc‐K8qMsJ3XQpwpQsnG7tK06YoYrjcnH5NW2EGjtemIc_00VIw
示例:
Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0IiwiY3JlYXRlZCI6MTU3NzE3NjE0OTQ3OCwiZXhwIjoxNTc3NzgwOTQ5fQ.qSlhJNpom2XeeqMyXST2AdHvAjztWqR4zvQQEc‐K8qMsJ3XQpwpQsnG7tK06YoYrjcnH5NW2EGjtemIc_00VIw
JWT身份认证流程:
1、用户提供用户名和密码登录
2、服务器校验用户是否正确,如正确,就返回token给客户端,此token可以包含用户信息
3、客户端存储token,可以保存在cookie或者local storage
4、客户端以后请求时,都要带上这个token,一般放在请求头中
5、服务器判断是否存在token,并且解码后就可以知道是哪个用户
6、服务器这样就可以返回该用户的相关信息了
1、用户提供用户名和密码登录
2、服务器校验用户是否正确,如正确,就返回token给客户端,此token可以包含用户信息
3、客户端存储token,可以保存在cookie或者local storage
4、客户端以后请求时,都要带上这个token,一般放在请求头中
5、服务器判断是否存在token,并且解码后就可以知道是哪个用户
6、服务器这样就可以返回该用户的相关信息了
多级缓存方式
- 当所有缓存都不存在的时候,需要请求mysql时需要加分布式锁,保证只有一个线程请求到mysql,防止大量的请求打到mysql中去
- 一级本地缓存需要设置缓存过期时间,毕竟都是放在jvm内存中,如果缓存多了会导致内存空间占用过多压垮服务器,可以使用Guava Cache
- redis缓存也是需要过期时间释放
- 所有的缓存都存在过期时间,如果过期时间一致,很有可能在缓存失效的那一瞬间大量请求打到mysql上(缓存雪崩)。解决办法:分布式锁保证只有一个请求打到mysql,设置随机的过期时间,减少击穿概率从而减少缓存雪崩
大厂中的设计要点
- 静态数据+CDN
- 不会变的文件静态数据放在CDN中,可能变动的文件,比如动态生成秒杀地址的JS(这些js是一开始不存在的,到点才生成);
- 比如这里可以使用mq的延迟队列去动态生成js文件到Nas盘,很多的系统直接Nas盘挂载,这样每台服务器都能直接获取到js文件
- 提前预热一些数据到CDN中
- 秒杀时间以服务器的为准,防止用户篡改本地时间,提前开始活动
- 活动未开始不提供秒杀地址,需要动态生成的JS到点获取秒杀地址
- DNS选择就近服务器,比如用户在北京就访问落到北京本地机房
- 当然还需要限流
中台
什么是中台?
所谓中台,就是将各个业务线中可以复用的一些功能抽取出来,剥离个性、提取共性,形成一些可复用的组件。通过这些组件,就可以使日后的系统开发成本降低,质量提高。大体上,中台可以分为三类,业务中台、数据中台和技术中台
业务中台
业务中台就是抽象出来,在各个业务线都可以共用的一些业务组件,像用户权限、会员管理、移动支付等等这些公用组件,可以在各个业务线中都使用。
数据中台
数据中台是对整个业务系统的数据进行统一存储、建模与计算,为各个业务系统的数据分析与利用提供支持。
技术中台
技术中台就是要封装各个业务系统所要采用的技术框架,使上层业务开发的门槛降低,提升交付速度
怎么建设中台?
中台的建设,也不仅仅是技术方面的问题,而是涉及到整个企业内资源调度的综合性问题。很多企业在组件软件开发团队时,都会优先基于业务的横向分割,形成产品、设计、开发、测试、运维这样大的组织结构,称为烟囱式团队。
例如阿里早期的团队建设是这样的
这种模式没有太多的历史技术和业务的包袱,在业务简单、参与的人比较少的时候,往往可以获得一个比较快的支付速度。但是随着项目的不端推进,业务变得越来越复杂,参与的团队会越来越多,这时候这种烟囱式的团队建设就会体现出很多的弊端。例如最为明显的就是功能重复建设以及维护会带来重复的投资。另外更深层次的问题在于,这种烟囱式的团队,团队之间的沟通和协作会非常困难。即不利于企业的业务经验沉淀,也不利于技术梯队的建设。在企业长期的发展过程中,就会体现出人力资源内耗严重,项目新需求研发越来越慢,交付的产品质量越来越低
例如阿里在09年就成立了共享事业部,尝试对于烟囱式团队进行改造。经过多年的摸索,才逐渐将平台化建设的思路给梳理清楚,并形成了中台这样一个具体的体系
中台的本质就是提炼各个业务的共同需求,进行业务和系统抽象,进而形成通用可复用的业务模型,打造成组件化产品,形成系统化的能力输出,供各个业务部门使用。简单来说,就是业务需要什么业务,需要什么资源,可以直接找中台,不需要每次去改动自己的底层。例如,如果一个支付企业在电商、零售等行业已经形成了非常丰富的支付业务能力,支持了非常多的支付场景,就可以沉淀形成支付中台。后续如果需要开发团购业务,需求团队就完全不需要自己开发支付方面的功能模块,直接找中台要就行。而在团购业务发展过程当中,有可能出现一些新的业务场景,需求团队可以交由支付中台统一提供支持,这样支付中台又能够进一步扩展自己支持的支付场景,以后在设计新的业务时,提供更全面的支付场景支持。这样,新业务的设计与开发可以像拼积木一样简单快速,而中台也能在业务实施过程中,不断吸收养分,逐渐壮大起来。整个中台体系在企业内就形成了一个良性循环。
怎么设计一个可落地的技术中台?
中台战略的形成,往往需要经过三个阶段的转化。
第一个阶段是从单体应用到微服务应用的转化:这个阶段跟业务系统转化是相同的。
第二个阶段是从微服务应用向平台化的转化:在这个阶段会逐渐将各个业务线的共有能力提炼并沉淀,集中形成一个一个的平台。
第三个阶段是从平台化建设向中台战略的转型:经过平台化的建设阶段后,技术中台就基本成型了。在以后的阶段,针对这些技术平台,再逐渐调整公司的整个组织架构以及资源投入,就形成了完整的中台战略
这个技术中台是更为广义的技术中台。在这个技术中台中需要实现的,是统一各个需求的技术栈以及由此衍生出来的测试、发布、运维等一系列的技术动作。通过这样的技术中台,各个业务团队的技术门槛得到极大的降低,开发的工作量减少了,测试运维的工作也都比较固定了,整个团队的工作模式也就越来越高效,可以将更多的精力集中用来深刻理解业务,并快速响应业务需求的变化
而在中台建设过程当中,技术中台往往是最为重要的一环。技术中台团队,往往需要由最精锐的开发人员组成。这样的技术中台需要功能全面、简单易用、部署简单、易于升级等等非常多的特点
特点
技术栈统一:采用SpringCloud技术栈,提供统一的架构支持。同步服务调用、异步消息通信,再到数据存储等功能,提供基于SpringBoot的拔插式支持,各应用只需要按需组合即可。在此基础上,提供统一配置、统一版本管理等集中式的管理方案。
解决方案统一:对微服务调用、MQ异步通知、日志脱敏、传输数据加密、缓存一致性等各种功能在框架中提供统一的解决方案。这些解决方案,大部分是以API的形式提供,业务方只需要进行调用即可。而如果不能形成API接口的,集成到代码静态检查规范当中,在编译发布的阶段给出统一的指导。
运维与框架统一:将外部依赖的组件与框架形成统一。例如某业务可能需要用到MQ做异步通信,会由技术中台团队完成MQ的部署,并且将MQ相关的实现以及配置信息一并上传到配置管理中心。业务团队在使用时甚至都不需要知道MQ部署地址,就可以直接拿来用
部署与运维统一:以Jenkins为基础,定制整套完整的部署运维方案。业务团队只需要往Git仓库中提交代码,后续项目打包以及测试环境的部署全部自动完成,不需要人工参与
上线方案统一:对线上环境,机器配置与服务部署都形成统一的标准,业务团队申请线上资源时,只需要申请自己需要什么,而不用管具体的细节。
所以,技术中台并不等同于框架设计,他的落地方案是多种多样的。我们可以说用SpringCloud做微服务是最好的,但是对于中台,很难说某一个公司的落地方案就一定是好的。不要简单的认为跟着互联网大厂学习了一下他们的技术或者架构,我们就做好了中台了。中台没有最好的,只有最适合的。从这个角度来说,我们这个简单的风控中台,对于我们这个电商项目,就可以是最好的中台
DDD领域驱动设计
DDD 作用
说到 DDD,绕不开 MVC,在 MVC 三层架构中,我们进行功能开发的之前,拿到需求,解读需求。往往最先做的一步就是先设计表结构,在逐层设计上层 dao,service,controller。对于产品或者用户的需求都做了一层自我理解的转化。
用户需求在被提出之后经过这么多层的转化后,特别是研发需求在数据库结构这一层转化后,将业务以主观臆断行为进行了转化。一旦业务边界划分模糊,考虑不全,大量的逻辑补充堆积到了代码层实现,变得越来越难维护。
假如我们现在要做一个电商订单下单的需求,涉及到用户选定商品,下订单、支付订单、对用户下单时的订单发货:
MVC 架构
我们常见的做法是在分析好业务需求之后,就开始设计表结构了,订单表,支付表,商品表等等。然后编写业务逻辑。这是第一个版本的需求,功能迭代饿了,订单支付后我可以取消,下单的商品我们退换货,是不是又需要进行加表,紧跟着对于的实现逻辑也进行修改。功能不断迭代,代码就不断的层层往上叠。
DDD 架构
我们先进行划分业务边界。这里面核心是订单。那么订单就是这个业务领域里面的聚合逻辑体现。支付,商品信息,地址等等都是围绕着订单实体。订单本身的属性决定之后,类似于地址只是一个属性的体现。当你将订单的领域模型构建好之后,后续的逻辑边界与仓储设计也就随之而来了。
DDD 整体作用总结如下
消除信息不对称;
常规MVC三层架构中自底向上的设计方式做一个反转,以业务为主导,自顶向下的进行业务领域划分;
将大的业务需求进行拆分,分而治之
DDD 分层架构
严格分层架构:某层只能与直接位于的下层发生耦合。
松散分层架构:允许上层与任意下层发生耦合。
在领域驱动设计(DDD)中采用的是松散分层架构,层间关系不那么严格。每层都可能使用它下面所有层的服务,而不仅仅是下一层的服务。每层都可能是半透明的,这意味着有些服务只对上一层可见,而有些服务对上面的所有层都可见
分层架构
用户交互层:web 请求,rpc 请求,mq 消息等外部输入均被视为外部输入的请求,可能修改到内部的业务数据。
业务应用层:与 MVC 中的 service 不同的不是,service 中存储着大量业务逻辑。但在应用服务的实现中,它负责编排、转发、校验等。
领域层:或称为模型层,系统的核心,负责表达业务概念,业务状态信息以及业务规则。即包含了该领域所有复杂的业务知识抽象和规则定义。该层主要精力要放在领域对象分析上,可以从实体,值对象,聚合(聚合根),领域服务,领域事件,仓储,工厂等方面入手
基础设施层:主要有 2 方面内容,一是为领域模型提供持久化机制,当软件需要持久化能力时候才需要进行规划;一是对其他层提供通用的技术支持能力,如消息通信,通用工具,配置等的实现
在设计和开发时,不要将本该放在领域层的业务逻辑放到应用层中实现,因为庞大的应用层会使领域模型失焦,时间一长你的服务就会演化为传统的三层架构,业务逻辑会变得混乱
各层数据转换
VO(View Object):视图对象,主要对应界面显示的数据对象。对于一个WEB页面,或者SWT、SWING的一个界面,用一个VO对象对应整个界面的值
DTO(Data Transfer Object):数据传输对象,主要用于远程调用等需要大量传输对象的地方。比如我们一张表有 100 个字段,那么对应的 PO 就有 100 个属性。但是我们界面上只要显示 10 个字段,客户端用 WEB service 来获取数据,没有必要把整个 PO 对象传递到客户端,这时我们就可以用只有这 10 个属性的 DTO 来传递结果到客户端,这样也不会暴露服务端表结构。到达客户端以后,如果用这个对象来对应界面显示,那此时它的身份就转为 VO。在这里,我泛指用于展示层与服务层之间的数据传输对象。
DO(Domain Object):领域对象,就是从现实世界中抽象出来的有形或无形的业务实体。
PO(Persistent Object):持久化对象,它跟持久层(通常是关系型数据库)的数据结构形成一一对应的映射关系,如果持久层是关系型数据库,那么,数据表中的每个字段(或若干个)就对应 PO 的一个(或若干个)属性。最形象的理解就是一个 PO 就是数据库中的一条记录,好处是可以把一条记录作为一个对象处理,可以方便的转为其它对象。
DDD 基础
领域和子域
在研究和解决业务问题时,DDD 会按照一定的规则将业务领域进行细分,当领域细分到一定的程度后,DDD 会将问题范围限定在特定的边界内,在这个边界内建立领域模型,进而用代码实现该领域模型,解决相应的业务问题。简言之,DDD 的领域就是这个边界内要解决的业务问题域。
领域可以进一步划分为子领域。我们把划分出来的多个子领域称为子域,每个子域对应一个更小的问题域或更小的业务范围
领域的核心思想就是将问题域逐级细分,来降低业务理解和系统实现的复杂度。通过领域细分,逐步缩小服务需要解决的问题域,构建合适的领域模型
举个简单的例子,对于保险领域,我们可以把保险细分为承保、收付、再保以及理赔等子域,而承保子域还可以继续细分为投保、保全(寿险)、批改(财险)等子子域
核心域、通用域和支撑域
核心域:决定产品和公司核心竞争力的子域,它是业务成功的主要因素和公司的核心竞争力。
通用域:没有太多个性化的诉求,同时被多个子域使用的通用功能的子域。
支撑域:但既不包含决定产品和公司核心竞争力的功能,也不包含通用功能的子域。
通用域:没有太多个性化的诉求,同时被多个子域使用的通用功能的子域。
支撑域:但既不包含决定产品和公司核心竞争力的功能,也不包含通用功能的子域。
核心域、支撑域和通用域的主要目标:通过领域划分,区分不同子域在公司内的不同功能属性和重要性,从而公司可对不同子域采取不同的资源投入和建设策略,其关注度也会不一样。
很多公司的业务,表面看上去相似,但商业模式和战略方向是存在很大差异的,因此公司的关注点会不一样,在划分核心域、通用域和支撑域时,其结果也会出现非常大的差异。
比如同样都是电商平台的淘宝、天猫、京东和苏宁易购,他们的商业模式是不同的。淘宝是 C2C 网站,个人卖家对个人买家,而天猫、京东和苏宁易购则是 B2C 网站,是公司卖家对个人买家。即便是苏宁易购与京东都是 B2C 的模式,苏宁易购是典型的传统线下卖场转型成为电商,京东则是直营加部分平台模式。
很多公司的业务,表面看上去相似,但商业模式和战略方向是存在很大差异的,因此公司的关注点会不一样,在划分核心域、通用域和支撑域时,其结果也会出现非常大的差异。
比如同样都是电商平台的淘宝、天猫、京东和苏宁易购,他们的商业模式是不同的。淘宝是 C2C 网站,个人卖家对个人买家,而天猫、京东和苏宁易购则是 B2C 网站,是公司卖家对个人买家。即便是苏宁易购与京东都是 B2C 的模式,苏宁易购是典型的传统线下卖场转型成为电商,京东则是直营加部分平台模式。
通用语言和限界上下文
通用语言
就是能够简单、清晰、准确描述业务涵义和规则的语言。
限界上下文
用来封装通用语言和领域对象,提供上下文环境,保证在领域之内的一些术语、业务相关对象等(通用语言)有一个确切的含义,没有二义性
实体和值对象
实体
实体 = 唯一身份标识 + 可变性【状态 + 行为】
DDD 中要求实体是唯一的且可持续变化的。意思是说在实体的生命周期内,无论其如何变化,其仍旧是同一个实体。唯一性由唯一的身份标识来决定的。可变性也正反映了实体本身的状态和行为。
实体以 DO(领域对象)的形式存在,每个实体对象都有唯一的 ID。我们可以对一个实体对象进行多次修改,修改后的数据和原来的数据可能会大不相同。
但是,由于它们拥有相同的 ID,它们依然是同一个实体。比如商品是商品上下文的一个实体,通过唯一的商品 ID 来标识,不管这个商品的数据如何变化,商品的 ID 一直保持不变,它始终是同一个商品
值对象
值对象 = 将一个值用对象的方式进行表述,来表达一个具体的固定不变的概念。
当你只关心某个对象的属性时,该对象便可作为一个值对象。 我们需要将值对象看成不变对象,不要给它任何身份标识,还应该尽量避免像实体对象一样的复杂性。
还是举个订单的例子,订单是一个实体,里面包含地址,这个地址可以只通过属性嵌入的方式形成的订单实体对象,也可以将地址通过 json 序列化一个 string 类型的数据,存到 DB 的一个字段中,那么这个 Json 串就是一个值对象
还是举个订单的例子,订单是一个实体,里面包含地址,这个地址可以只通过属性嵌入的方式形成的订单实体对象,也可以将地址通过 json 序列化一个 string 类型的数据,存到 DB 的一个字段中,那么这个 Json 串就是一个值对象
聚合和聚合根
聚合
聚合:我们把一些关联性极强、生命周期一致的实体、值对象放到一个聚合里。聚合是领域对象的显式分组,旨在支持领域模型的行为和不变性,同时充当一致性和事务性边界。
聚合有一个聚合根和上下文边界,这个边界根据业务单一职责和高内聚原则,定义了聚合内部应该包含哪些实体和值对象,而聚合之间的边界是松耦合的。按照这种方式设计出来的服务很自然就是“高内聚、低耦合”的。
聚合在 DDD 分层架构里属于领域层,领域层包含了多个聚合,共同实现核心业务逻辑。跨多个实体的业务逻辑通过领域服务来实现,跨多个聚合的业务逻辑通过应用服务来实现。
比如有的业务场景需要同一个聚合的 A 和 B 两个实体来共同完成,我们就可以将这段业务逻辑用领域服务来实现;而有的业务逻辑需要聚合 C 和聚合 D 中的两个服务共同完成,这时你就可以用应用服务来组合这两个服务
聚合在 DDD 分层架构里属于领域层,领域层包含了多个聚合,共同实现核心业务逻辑。跨多个实体的业务逻辑通过领域服务来实现,跨多个聚合的业务逻辑通过应用服务来实现。
比如有的业务场景需要同一个聚合的 A 和 B 两个实体来共同完成,我们就可以将这段业务逻辑用领域服务来实现;而有的业务逻辑需要聚合 C 和聚合 D 中的两个服务共同完成,这时你就可以用应用服务来组合这两个服务
聚合根
如果把聚合比作组织,那聚合根就是这个组织的负责人。聚合根也称为根实体,它不仅是实体,还是聚合的管理者。
• 首先它作为实体本身,拥有实体的属性和业务行为,实现自身的业务逻辑。
• 其次它作为聚合的管理者,在聚合内部负责协调实体和值对象按照固定的业务规则协同完成共同的业务逻辑。
• 最后在聚合之间,它还是聚合对外的接口人,以聚合根 ID 关联的方式接受外部任务和请求,在上下文内实现聚合之间的业务协同。也就是说,聚合之间通过聚合根 ID 关联引用,如果需要访问其它聚合的实体,就要先访问聚合根,再导航到聚合内部实体,外部对象不能直接访问聚合内实体。
领域服务和应用服务
领域服务
当一些逻辑不属于某个实体时,可以把这些逻辑单独拿出来放到领域服务中,理想的情况是没有领域服务,如果领域服务使用不恰当,慢慢又演化回了以前逻辑都在 service 层的局面
可以使用领域服务的情况:
• 执行一个显著的业务操作
• 对领域对象进行转换
• 以多个领域对象作为输入参数进行计算,结果产生一个值对象
应用服务
应用层作为展现层与领域层的桥梁,是用来表达用例和用户故事的主要手段。
应用层通过应用服务接口来暴露系统的全部功能。在应用服务的实现中,它负责编排和转发,它将要实现的功能委托给一个或多个领域对象来实现,它本身只负责处理业务用例的执行顺序以及结果的拼装。通过这样一种方式,它隐藏了领域层的复杂性及其内部实现机制。
应用层相对来说是较“薄”的一层,除了定义应用服务之外,在该层我们可以进行安全认证,权限校验,持久化事务控制,或者向其他系统发生基于事件的消息通知,另外还可以用于创建邮件以发送给客户等。
应用层通过应用服务接口来暴露系统的全部功能。在应用服务的实现中,它负责编排和转发,它将要实现的功能委托给一个或多个领域对象来实现,它本身只负责处理业务用例的执行顺序以及结果的拼装。通过这样一种方式,它隐藏了领域层的复杂性及其内部实现机制。
应用层相对来说是较“薄”的一层,除了定义应用服务之外,在该层我们可以进行安全认证,权限校验,持久化事务控制,或者向其他系统发生基于事件的消息通知,另外还可以用于创建邮件以发送给客户等。
领域事件
领域事件 = 事件发布 + 事件存储 + 事件分发 + 事件处理。
领域事件是一个领域模型中极其重要的部分,用来表示领域中发生的事件。忽略不相关的领域活动,同时明确领域专家要跟踪或希望被通知的事情,或与其他模型对象中的状态更改相关联
下面简单说明领域事件:
• 事件发布:构建一个事件,需要唯一标识,然后发布;
• 事件存储:发布事件前需要存储,因为接收后的事建也会存储,可用于重试或对账等;
• 事件分发:服务内直接发布给订阅者,服务外需要借助消息中间件,比如Kafka,RabbitMQ等;
• 事件处理:先将事件存储,然后再处理。
比如下订单后,给用户增长积分与赠送优惠券的需求。如果使用瀑布流的方式写代码。一个个逻辑调用,那么不同用户,赠送的东西不同,逻辑就会变得又臭又长。
这里的比较好的方式是,用户下订单成功后,发布领域事件,积分聚合与优惠券聚合监听订单发布的领域事件进行处理。
这里的比较好的方式是,用户下订单成功后,发布领域事件,积分聚合与优惠券聚合监听订单发布的领域事件进行处理。
资源库【仓储】
仓储介于领域模型和数据模型之间,主要用于聚合的持久化和检索。它隔离了领域模型和数据模型,以便我们关注于领域模型而不需要考虑如何进行持久化。
我们将暂时不使用的领域对象从内存中持久化存储到磁盘中。当日后需要再次使用这个领域对象时,根据 key 值到数据库查找到这条记录,然后将其恢复成领域对象,应
用程序就可以继续使用它了,这就是领域对象持久化存储的设计思想。
我们将暂时不使用的领域对象从内存中持久化存储到磁盘中。当日后需要再次使用这个领域对象时,根据 key 值到数据库查找到这条记录,然后将其恢复成领域对象,应
用程序就可以继续使用它了,这就是领域对象持久化存储的设计思想。
非对称加密和对称加密的区别
对称加密算法
密钥较短,破译困难,除了数据加密标准(DES),另一个对称密钥加密系统是国际数据加密算法(IDEA),它比DES的加密性好,且对计算机性能要求也没有那么高
优点
算法公开、计算量小、加密速度快、加密效率高
缺点
- 在数据传送前,发送方和接收方必须商定好秘钥,然后 使双方都能保存好秘钥。其次如果一方的秘钥被泄露,那么加密信息也就不安全了。
- 另外,每对用户每次使用对称加密算法时,都需要使用其他人不知道的唯一秘钥,这会使得收、发双方所拥有的钥匙数量巨大,密钥管理成为双方的负担
加密方式:加密和解密的秘钥使用的是同一个
常见的对称加密算法
DES、3DES、Blowfish、IDEA、RC4、RC5、RC6 和 AES
非对称加密算法
公开密钥与私有密钥是一对,如果用公开密钥对数据进行加密,只有用对应的私有密钥才能解密;如果用私有密钥对数据进行加密,那么只有用对应的公开密钥才能解密。因为加密和解密使用的是两个不同的密钥,所以这种算法叫作非对称加密算法
信息交换的基本过程
甲方生成一对密钥并将其中的一把作为公用密钥向其它方公开;得到该公用密钥的乙方使用该密钥对机密信息进行加密后再发送给甲方;甲方再用自己保存的另一把专用密钥对加密后的信息进行解密。甲方只能用其专用密钥解密由其公用密钥加密后的任何信息
安全但是速度慢
常见的非对称加密算法
RSA、ECC(移动设备用)、Diffie-Hellman、El Gamal、DSA(数字签名用)
http与https
http
特点
- 无状态:协议对客户端没有状态存储,对事物处理没有“记忆”能力,比如访问一个网站需要反复进行登录操作
- 无连接:HTTP/1.1之前,由于无状态特点,每次请求需要通过TCP三次握手四次挥手,和服务器重新建立连接。比如某个客户机在短时间多次请求同一个资源,服务器并不能区别是否已经响应过用户的请求,所以每次需要重新响应请求,需要耗费不必要的时间和流量。
- 基于请求和响应:基本的特性,由客户端发起请求,服务端响应
- 简单快速、灵活
- 通信使用明文、请求和响应不会对通信方进行确认、无法保护数据的完整性
HTTPS
特点
- 内容加密:采用混合加密技术,中间者无法直接查看明文内容
- 验证身份:通过证书认证客户端访问的是自己的服务器
- 保护数据完整性:防止传输的内容被中间人冒充或者篡改
混合加密:结合非对称加密和对称加密技术。客户端使用对称加密生成密钥对传输数据进行加密,然后使用非对称加密的公钥再对秘钥进行加密,所以网络上传输的数据是被秘钥加密的密文和用公钥加密后的秘钥,因此即使被黑客截取,由于没有私钥,无法获取到加密明文的秘钥,便无法获取到明文数据
数字摘要:通过单向hash函数对原文进行哈希,将需加密的明文“摘要”成一串固定长度(如128bit)的密文,不同的明文摘要成的密文其结果总是不相同,同样的明文其摘要必定一致,并且即使知道了摘要也不能反推出明文
数字签名技术:数字签名建立在公钥加密体制基础上,是公钥加密技术的另一类应用。它把公钥加密技术和数字摘要结合起来,形成了实用的数字签名技术
非对称加密过程需要用到公钥进行加密,那么公钥从何而来?
其实公钥就被包含在数字证书中,数字证书通常来说是由受信任的数字证书颁发机构CA,在验证服务器身份后颁发,证书中包含了一个密钥对(公钥和私钥)和所有者识别信息。数字证书被放到服务端,具有服务器身份验证和数据传输加密功能。
分布式主键策略
SNOWFLAKE算法
雪花算法,能够保证不同进程主键的不重复性,相同进程主键的有序性。二进制形式包含4部分,从高位到低位分表为:1bit符号位、41bit时间戳位、10bit工作进程位以及12bit序列号位
组成部分
符号位(1bit)
预留的符号位,恒为零,为1会变成负数
时间戳位(41bit)
41位的时间戳可以容纳的毫秒数是2的41次幂,一年所使用的毫秒数是:365 * 24 * 60 * 60 * 1000 = 31,536,000,000(三百多亿)
Math.pow(2, 41) / (365 * 24 * 60 * 60 * 1000L) = 69.73年不重复;
工作进程位(10bit)
该标志在Java进程内是唯一的,如果是分布式应用部署应保证每个工作进程的id是不同的。该值默认为0,可通过属性设置。
序列号位(12bit)
该序列是用来在同一个毫秒内生成不同的ID。如果在这个毫秒内生成的数量超过4096(2的12次幂),那么生成器会等待到下个毫秒继续生成
不同机器生成的雪花id如何保证全局有序
不同机器生成的雪花 ID 由于机器 ID、时间戳(不同机器间时间可能存在细小差别)和序列号等参数的不同,可能导致生成的 ID 不具有全局有序性。为了保证全局有序性,可以采用以下两种方法来避免不同机器生成的 ID 在排序时产生混乱。由于机器id导致的非全局有序极小可能
使用分布式锁:可以使用各种分布式锁如 ZooKeeper、Redis 等来控制不同机器之间的互斥访问。在生成 ID 的时候采用分布式锁来保证只有一个节点可以获取 ID,从而避免不同机器生成的 ID 发生混乱。这种方法需要增加额外的业务代码和分布式锁实现,实现难度比较高,但可以达到较好的性能和实时性。
采用全局时钟:该方法可以使用 NTP(Network Time Protocol)等协议来同步不同机器的时钟,以保证各个节点之间时钟的一致性,从而保证生成的 ID 具有全局有序性(机器位需要一致的情况)。这种方法实现简单,但由于网络时延等因素的影响,可能存在不同机器之间时钟不完全一致的情况,需要使用一定的时钟同步机制来保证时钟的一致性。
综上所述,同时考虑多机器生成的 ID 全局有序性和性能问题,在实际应用中可以采用一些分布式 ID 生成器,如 Snowflake 算法等来生成唯一序列号,同时使用分布式锁、全局时钟同步等机制来保证 ID 全局有序性和唯一性。
使用分布式锁:可以使用各种分布式锁如 ZooKeeper、Redis 等来控制不同机器之间的互斥访问。在生成 ID 的时候采用分布式锁来保证只有一个节点可以获取 ID,从而避免不同机器生成的 ID 发生混乱。这种方法需要增加额外的业务代码和分布式锁实现,实现难度比较高,但可以达到较好的性能和实时性。
采用全局时钟:该方法可以使用 NTP(Network Time Protocol)等协议来同步不同机器的时钟,以保证各个节点之间时钟的一致性,从而保证生成的 ID 具有全局有序性(机器位需要一致的情况)。这种方法实现简单,但由于网络时延等因素的影响,可能存在不同机器之间时钟不完全一致的情况,需要使用一定的时钟同步机制来保证时钟的一致性。
综上所述,同时考虑多机器生成的 ID 全局有序性和性能问题,在实际应用中可以采用一些分布式 ID 生成器,如 Snowflake 算法等来生成唯一序列号,同时使用分布式锁、全局时钟同步等机制来保证 ID 全局有序性和唯一性。
时间回拔问题造成不唯一
延迟等待
这种时间回拨(回跳)或许只出现一次,也许只是机器出现了小问题,所以产生
对于这种场景,没有必要抛出异常,中断业务
此时,将当前线程阻塞3ms,之后再获取时间,看时间是否比上一次请求的时间大
如果大了,说明恢复正常了,则不用管
如果还小,说明真出问题了,则抛出异常,呼唤程序员处理
实际应用项目: 美团的leaf, 用如果时间差在5ms内,则等待 时间差<<1, 然后再判断
采用之前最大时间
本身得出时间回拨结论就是通过当前时间和上次最后(大)的时间进行比较
那么此时可以采用上次最大时间的最大序号之后的序号来进行继续使用
从而保证了唯一性
追赶时间
可以采取这样的暴力思路,因为当前的时间回拨了,比之前的时间慢
那么我们便加速追赶时间
首先,不返回id
然后将我们的seq增加比如1024个,然后判断是否回拨,如果不是,再加1024
当seq超过了12位的maxSeq时,按照雪花算法的逻辑,时间便会进位,借用下个时间的seq
此时就实现了时间的加速
经过若干个加速,则可以实现时间正常
uuid
uuid有序吗
UUID(通用唯一标识符)是一种标识符,用于在计算机系统上唯一地标识信息。UUID 格式是一个 16 字节(128 位)长数字,可以表达 2^128 个可能的值,这使得它几乎可以保证唯一性。但是,UUID 并不是有序的。
UUID 采用的是随机数算法来确定标识符,它生成的 UUID 并不是按照时间顺序生成的,也不存在基于时间生成的可排序 UUID。因此,UUID 是无序的,即不存在时间顺序或数字顺序的关系。对于 UUID 生成算法的实现而言,并没有以有序的方式实现。
但是,在某些场景下,可以采用类似于基于时间的 UUID 版本号(如 v1 和 v2 版本),依赖于时钟和节点号的方式来生成唯一 ID,并在一定程度上实现时间上的有序性。但是这种基于时间的 UUID 版本号存在可追溯性问题,会暴露出主机名和时间等信息,因此在某些情况下不适用。
综上所述,UUID 是一种无序的标识符,虽然可以通过某些实现方式实现时间上的有序性,但这种方式存在可追溯性问题,并不建议使用。如果需要有序 ID,可以考虑使用基于 Snowflake 算法等实现方式来生成有序 ID。
uuid怎么生成的
UUID(通用唯一标识符)是一种由一组 128 位数所构成的 GUID,它具有唯一性和高度随机性。常用的 UUID 版本有 1、2、3、4 和 5 五个版本,其中最常用的是版本 1 和版本 4。下面介绍如何生成版本 1 和版本 4 的 UUID。
版本 1 的 UUID 生成方法:版本 1 的 UUID 是基于时间和系统 MAC 地址来生成的,可以通过以下步骤生成:
获取系统当前时间,并将其转化为 100 纳秒为单位的时间戳(time_low 和 time_high 字段),并对其进行格式化。
为 clock_sequence 字段生成一个 14 位的随机数,来保证和其他计算机的时间冲突率降到最低。
获取 MAC 地址,并将其转化成 48 bits 的整型数(node 字段),如果无法获取 MAC 地址,则使用伪随机数来代替。
将各字段组合到一起,生成一个 128 位的 UUID。
版本 4 的 UUID 生成方法:版本 4 的 UUID 是基于随机数生成的,可以通过以下步骤生成:
随机生成 16 个字节(128 位)的值,并对其中的几个特定比特进行设置,以满足 UUID 格式的要求。
将第 6 个字节的高 4 位设置为 4,以标识版本号。
将第 8 个字节的高 2 位设为 01,以标识 UUID 是随机生成的。
将生成的 128 位的值分为 5 个部分,分别是 time_low、time_mid、time_high_and_version、clock_seq_hi_res 和 clock_seq_low,然后组合成 UUID。
注:除了版本 1 和版本 4,还有一些其它版本的 UUID,例如基于名称的 UUID(版本 3 和版本 5)等,具体生成方式略有不同。
总之,UUID 的生成方式是基于时间戳或随机数的方法来生成唯一的字符串标识符。不同版本的 UUID 由于采用不同的生成方法,具有不同的特点和用途,能够满足不同的需求。
七层网络协议
概念
物理层
解决两个硬件之间怎么通信的问题,常见的物理媒介有光纤、电缆、中继器等。它主要定义物理设备标准,如网线的接口类型、光纤的接口类型、各种传输介质的传输速率等。
通过硬件设备将模拟信号转换为数字信号,于是有了0/1数据流,叫做比特流。
数据链路层
在计算机网络中由于各种干扰的存在,物理链路是不可靠的。该层的主要功能就是:通过各种控制协议,将有差错的物理信道变为无差错的、能可靠传输数据帧的数据链路
可以发比特流但是没有格式就会乱七八糟,于是就有了”帧”。采用了一种”帧”的数据块进行传输,为了确保数据通信的准确,实现数据有效的差错控制,加入了检错等功能
网络层
计算机网络中如果有多台计算机,怎么找到要发的那台?如果中间有多个节点,怎么选择路径?这就是路由要做的事
该层的主要任务就是:通过路由选择算法,为报文(该层的数据单位,由上一层数据打包而来)通过通信子网选择最适当的路径。这一层定义的是IP地址,通过IP地址寻址,所以产生了IP协议
传输层
当发送大量数据时,很可能会出现丢包的情况,另一台电脑要告诉是否完整接收到全部的包。如果缺了,就告诉丢了哪些包,然后再发一次,直至全部接收为止
比特流传输的过程不可能会一直顺畅,偶尔出现中断很正常,如果人为制定出单位,分成一个个的信息段,从中又衍生了报文,结合上面几层,我们就可以有目标的发生正确数据给某台计算机了,传输层有两个重要的协议:TCP和UDP。TCP效率低但是发送包会校验是否完整,UDP效率高但是不管别人能否完整收到。
会话层
虽然已经可以实现给正确的计算机,发送正确的封装过后的信息了。但我们总不可能每次都要调用传输层协议去打包,然后再调用IP协议去找路由,所以我们要建立一个自动收发包,自动寻址的功能。于是会话层出现了:它的作用就是建立和管理应用程序之间的通信
计算机收到了发送的数据,但是有那么多进程,具体哪个进程需要用到这个数据,则把他输送到那个进程。例如:如果80端口要用,所以系统内数据通信,将接收端口数据送至需求端口。
计算机收到了发送的数据,但是有那么多进程,具体哪个进程需要用到这个数据,则把他输送到那个进程。例如:如果80端口要用,所以系统内数据通信,将接收端口数据送至需求端口。
表示层
表示层负责数据格式的转换,将应用处理的信息转换为适合网络传输的格式,或者将来自下一层的数据转换为上层能处理的格式
现在正确接收到了需要的数据,但是因为数据在传输过程中可能基于安全性,或者是算法上的压缩,还有就是网络类型不同。那就得有一个沟通的桥梁来整理整理,还原出原本应该有的表示,类似于一个拆快递的过程。
应用层
应用层是计算机用户,以及各种应用程序和网络之间的接口,其功能是直接向用户提供服务,完成用户希望在网络上完成的各种工作。前端同学对应用层肯定是最熟悉的
是其他层对用户的已经封装好的接口,提供多种服务,用户只需操作应用层就可以得到服务内容,这样封装可以让更多的人能使用它。包含的主要协议:FTP(文件传送协议)、Telnet(远程登录协议)、DNS(域名解析协议)、SMTP(邮件传送协议),POP3协议(邮局协议),HTTP协议(Hyper Text Transfer Protocol)
物链网传话表应
CRC32哈希值
哈希函数是计算中的重要工具,用于将任意大小的数据映射到一个固定的大小。其中使用最广泛的是循环冗余检查(CRC)算法,它可以用来确保数据的完整性,检测错误代码,以及识别数据库中的重复数据
什么是CRC32哈希
CRC32哈希是一个32位的哈希函数,对任何大小的数据块进行循环冗余检查,并返回一个固定长度的校验和。由此产生的校验和对输入数据来说是唯一的,这使得它适用于验证数据在传输或存储过程中是否被改变、破坏或无意损坏。
CRC32算法是基于一个数学公式,生成一个32度的多项式,代表校验和。该函数对输入的数据进行迭代,将其划分为若干块,并使用多项式对每块数据进行计算
CRC32算法是基于一个数学公式,生成一个32度的多项式,代表校验和。该函数对输入的数据进行迭代,将其划分为若干块,并使用多项式对每块数据进行计算
误解和常见问题
CRC32散列函数是一个简单而快速的散列函数,广泛用于数据完整性检查、错误检测和识别数据库中的重复内容。它很容易实现,产生哈希碰撞的可能性很低。开发人员可以使用CRC32 Hash来验证数据的完整性,并检测由非故意损坏、恶意篡改或数据存储错误等情况引起的变化。
CRC32算法生成的数字是32位的,因此总共有2^32种可能的数字。因此,在理论上,CRC32重复的概率是非常低的。
然而,实际上,在极端情况下,CRC32可能会重复。例如,如果数据集非常小,并且生成的CRC32值的数量也很小,则可能会出现重复的情况。
然而,实际上,在极端情况下,CRC32可能会重复。例如,如果数据集非常小,并且生成的CRC32值的数量也很小,则可能会出现重复的情况。
子主题
CRC32算法是将需要传输的数据看成二进制多项式,对这个多项式进行除法运算,计算出余数作为校验值。具体实现中,CRC32算法使用一个32位的寄存器来存储计算过程中的状态,并通过按位异或(XOR)等操作来不断更新寄存器中的值。最终计算得到的32位二进制数即为校验值。
CRC32算法中的多项式是固定的,常用的多项式有以下两种:
CRC-32-IEEE 802.3多项式(0xEDB88320)
CRC-32C多项式(0x1EDC6F41)
其中,CRC-32C多项式比CRC-32-IEEE 802.3多项式更加安全,因为它的多项式中包含了更多的低阶项
Apache HBase
HBase 是一个开源、分布式、面向列的数据库,用于非结构化和实时数据存储、访问和分析
HBase 采用的时key/value的存储方式,这意味着,即使随着数据量的增大,也几乎不会导致查询性能的下降。HBase 又是一个面向列存储的数据库,当表的字段很多时,可以把其中几个字段独立出来放在一部分机器上,而另外几个字段放到另一部分机器上,充分分散了负载的压力。如此复杂的存储结构和分布式的存储方式,带来的代价就是即便是存储很少的数据,也不会很快
HBase 并不是足够快,只是数据量很大的时候慢的不明显。HBase主要用在以下两种情况:
- 单表数据量超过千万,而且并发量很大。
- 数据分析需求较弱,或者不需要那么实时灵活
降级、熔断、限流
降级
定义
主要是针对非核心业务功能,而核心业务如果流程超过预估的峰值,就需要进行限流。降级一般考虑的是分布式系统的整体性,从源头上切断流量的来源。降级更像是预估手段,在预计流量峰值前提下,提前通过配置功能降低服务体验,或暂停次要功能,保证系统主要流程功能平稳响应
降级的方式有哪些?
1、将强一致性变成最终一致性,不需要强一致性的功能,可以通过消息队列进行削峰填谷,变为最终一致性达到应用程序想要的效果。
2、停止访问一些次要功能,释放出更多资源。比如双十一不让退货等。
3、简化功能流程,把一些复杂的流程简化。提高访问效率。
2、停止访问一些次要功能,释放出更多资源。比如双十一不让退货等。
3、简化功能流程,把一些复杂的流程简化。提高访问效率。
自动降级的条件
调用失败次数达到阈值
请求响应超时时间达到阈值
请求下游服务发生故障通过状态码
流量达到阈值触发服务降级
请求响应超时时间达到阈值
请求下游服务发生故障通过状态码
流量达到阈值触发服务降级
熔断
定义
是在流量过大时(或下游服务出现问题时),可以自动断开与下游服务的交互,并可以通过自我诊断下游系统的错误是否已经修正,或上游流量是否减少至正常水平,来恢复自我恢复。熔断更像是自动化补救手段,可能发生在服务无法支撑大量请求或服务发生其他故障时,对请求进行限制处理,同时还可尝试性的进行恢复。
限流
定义
限流是流量限速(Rate Limit)的简称,是指只允许指定的事件进入系统,超过的部分将被拒绝服务、排队或等待、降级等处理。对于server服务而言,限流为了保证一部分的请求流量可以得到正常的响应,总好过全部的请求都不能得到响应,甚至导致系统雪崩
限流算法
计数器算法(固定窗口)
在一段时间间隔内(时间窗)处理请求的最大数量固定,超过部分不做处理
缺点(不满足滑动时间)
在两个间隔之间,如果有密集的请求。则会导致单位时间内的实际请求超过阈值
滑动窗口算法
滑动窗口为固定窗口的改良版,解决了固定窗口在窗口切换时会受到两倍于阈值数量的请求,滑动窗口在固定窗口的基础上,将一个窗口分为若干个等份的小窗口,每个小窗口对应不同的时间点,拥有独立的计数器,当请求的时间点大于当前窗口的最大时间点时,则将窗口向前平移一个小窗口(将第一个小窗口的数据舍弃,第二个小窗口变成第一个小窗口,当前请求放在最后一个小窗口),整个窗口的所有请求数相加不能大于阈值。其中,Sentinel就是采用滑动窗口算法来实现限流的
漏桶算法
请求直接进入到漏桶里,漏桶以一定的速度对请求进行放行,当请求数量递增,漏桶内的总请求量大于桶容量就会直接溢出,请求被拒绝
漏桶限流规则1)请求以任意速率流入漏桶。2)漏桶的容量是固定的,放行速率也是固定的。3)漏桶容量是不变的,如果处理速度太慢,桶内请求数量会超出桶的容量,则后面流入的请求会溢出,表示请求被拒绝
缺点
漏桶的出水速度是固定的,也就是请求放行速度是固定的,故漏桶不能有效应对突发流量,但是能起到平滑突发流量(整流)的作用
令牌桶算法
令牌桶算法以一个设定的速率产生令牌并放入令牌桶,每次用户请求都得申请令牌,如果令牌不足,则拒绝请求
令牌的数量与时间和发放速率强相关,流逝的时间越长,往桶里加入令牌的越多,如果令牌发放速度比申请速度快,则令牌会放入令牌桶,直到占满整个令牌桶,令牌桶满了,多的令牌就直接丢弃
令牌桶限流大致的规则如下1)进水口按照某个速度向桶中放入令牌。2)令牌桶的容量是固定的,但是放行的速度不是固定的,只要桶中还有剩余令牌,一旦请求过来就能申请成功,然后放行。3)如果令牌的发放速度慢于请求的到来速度,则桶内就无牌可领,请求就会被拒绝
ShardingSphere
数据库分片与读写分离
数据库分片(分库分表)
背景
传统的将数据集中存储至单一数据节点的解决方案,在性能、可用性和运维成本这三方面已经难于满足互联网的海量数据场景
- 从性能方面来说,由于关系型数据库大多采用B+树类型的索引,在数据量超过阈值的情况下,索引深度的增加也将使得磁盘访问的IO次数增加,进而导致查询性能的下降;同时,高并发访问请求也使得集中式数据库成为系统的最大瓶颈。
- 从可用性的方面来讲,服务化的无状态型,能够达到较小成本的随意扩容,这必然导致系统的最终压力都落在数据库之上。而单一的数据节点,或者简单的主从架构,已经越来越难以承担。数据库的可用性,已成为整个系统的关键。
- 从运维成本方面考虑,当一个数据库实例中的数据达到阈值以上,对于DBA的运维压力就会增大。数据备份和恢复的时间成本都将随着数据量的大小而愈发不可控。一般来讲,单一数据库实例的数据的阈值在1TB之内,是比较合理的范围。
数据分片指按照某个维度将存放在单一数据库中的数据分散地存放至多个数据库或表中以达到提升性能瓶颈以及可用性的效果。 数据分片的有效手段是对关系型数据库进行分库和分表。分库和分表均可以有效的避免由数据量超过可承受阈值而产生的查询瓶颈。 除此之外,分库还能够用于有效的分散对数据库单点的访问量;分表虽然无法缓解数据库压力,但却能够提供尽量将分布式事务转化为本地事务的可能,一旦涉及到跨库的更新操作,分布式事务往往会使问题变得复杂。 使用多主多从的分片方式,可以有效的避免数据单点,从而提升数据架构的可用性
通过分库和分表进行数据的拆分来使得各个表的数据量保持在阈值以下,以及对流量进行疏导应对高访问量,是应对高并发和海量数据系统的有效手段。 数据分片的拆分方式又分为垂直分片和水平分片。
能不分尽量不分、不要过度拆分
能不分尽量不分、不要过度拆分
垂直分片
按照业务拆分的方式称为垂直分片,又称为纵向拆分,它的核心理念是专库专用。 在拆分之前,一个数据库由多个数据表构成,每个表对应着不同的业务。而拆分之后,则是按照业务将表进行归类,分布到不同的数据库中,从而将压力分散至不同的数据库。 下图展示了根据业务需要,将用户表和订单表垂直分片到不同的数据库的方案
垂直分片往往需要对架构和设计进行调整。通常来讲,是来不及应对互联网业务需求快速变化的;而且,它也并无法真正的解决单点瓶颈。 垂直拆分可以缓解数据量和访问量带来的问题,但无法根治。如果垂直拆分之后,表中的数据量依然超过单节点所能承载的阈值,则需要水平分片来进一步处理
水平分片
水平分片又称为横向拆分。 相对于垂直分片,它不再将数据根据业务逻辑分类,而是通过某个字段(或某几个字段),根据某种规则将数据分散至多个库或表中,每个分片仅包含数据的一部分。 例如:根据主键分片,偶数主键的记录放入0库(或表),奇数主键的记录放入1库(或表),如下图所示
水平分片从理论上突破了单机数据量处理的瓶颈,并且扩展相对自由,是分库分表的标准解决方案
引来的问题-挑战
虽然数据分片解决了性能、可用性以及单点备份恢复等问题,但分布式的架构在获得了收益的同时,也引入了新的问题。面对如此散乱的分库分表之后的数据,应用开发工程师和数据库管理员对数据库的操作变得异常繁重就是其中的重要挑战之一。他们需要知道数据需要从哪个具体的数据库的分表中获取。另一个挑战则是,能够正确的运行在单节点数据库中的SQL,在分片之后的数据库中并不一定能够正确运行。例如,分表导致表名称的修改,或者分页、排序、聚合分组等操作的不正确处理。跨库事务也是分布式的数据库集群要面对的棘手事情。 合理采用分表,可以在降低单表数据量的情况下,尽量使用本地事务,善于使用同库不同表可有效避免分布式事务带来的麻烦。 在不能避免跨库事务的场景,有些业务仍然需要保持事务的一致性。 而基于XA的分布式事务由于在并发度高的场景中性能无法满足需要,并未被互联网巨头大规模使用,他们大多采用最终一致性的柔性事务代替强一致事务
分库分表组件
- shardingsphere(京东数科)在apache孵化
- Mycat(阿里巴巴-基于cobar)不是阿里的
- Tddl
- Atlas (奇虎360)
读写分离
面对日益增加的系统访问量,数据库的吞吐量面临着巨大瓶颈。 对于同一时刻有大量并发读操作和较少写操作类型的应用系统来说,将数据库拆分为主库和从库,主库负责处理事务性的增删改操作,从库负责处理查询操作,能够有效的避免由数据更新导致的行锁,使得整个系统的查询性能得到极大的改善。通过一主多从的配置方式,可以将查询请求均匀的分散到多个数据副本,能够进一步的提升系统的处理能力。 使用多主多从的方式,不但能够提升系统的吞吐量,还能够提升系统的可用性,可以达到在任何一个数据库宕机,甚至磁盘物理损坏的情况下仍然不影响系统的正常运行。与将数据根据分片键打散至各个数据节点的水平分片不同,读写分离则是根据SQL语义的分析,将读操作和写操作分别路由至主库与从库
读写分离的数据节点中的数据内容是一致的,而水平分片的每个数据节点的数据内容却并不相同。将水平分片和读写分离联合使用,能够更加有效的提升系统性能
引来的问题-挑战
读写分离虽然可以提升系统的吞吐量和可用性,但同时也带来了数据不一致的问题。 这包括多个主库之间的数据一致性,以及主库与从库之间的数据一致性的问题。 并且,读写分离也带来了与数据分片同样的问题,它同样会使得应用开发和运维人员对数据库的操作和运维变得更加复杂。 下图展现了将分库分表与读写分离一同使用时,应用程序与数据库集群之间的复杂拓扑关系
ShardingSphere快速开始与核心概念
ShardingSphere概览
ShardingSphere是一套开源的分布式数据库中间件解决方案组成的生态圈,它由Sharding-JDBC、Sharding-Proxy和Sharding-Sidecar(计划中)这3款相互独立的产品组成。 他们均提供标准化的数据分片、分布式事务和数据库治理功能,可适用于如Java同构、异构语言、云原生等各种多样化的应用场景
ShardingSphere定位为关系型数据库中间件,旨在充分合理地在分布式的场景下利用关系型数据库的计算和存储能力,而并非实现一个全新的关系型数据库。 它与NoSQL和NewSQL是并存而非互斥的关系。NoSQL和NewSQL作为新技术探索的前沿,放眼未来,拥抱变化,是非常值得推荐的。反之,也可以用另一种思路看待问题,放眼未来,关注不变的东西,进而抓住事物本质。 关系型数据库当今依然占有巨大市场,是各个公司核心业务的基石,未来也难于撼动,我们目前阶段更加关注在原有基础上的增量,而非颠覆。
Sharding-JDBC
Sharding-JDBC是ShardingSphere的第一个产品,也是ShardingSphere的前身。它定位为轻量级Java框架,在Java的JDBC层提供的额外服务。它使用客户端直连数据库,以jar包形式提供服务,无需额外部署和依赖,可理解为增强版的JDBC驱动,完全兼容JDBC和各种ORM框架。
- 适用于任何基于JDBC的ORM框架:JPA, Hibernate, Mybatis, Spring JDBC Template或直接使用JDBC。
- 支持任何第三方的数据库连接池:DBCP, C3P0, BoneCP, Druid, HikariCP等。
- 支持任意实现JDBC规范的数据库。支持MySQL,Oracle,SQLServer,PostgreSQL等遵循
- SQL92标准的数据库
Sharding-Proxy
Sharding-Proxy是ShardingSphere的第二个产品。 它定位为透明化的数据库代理端,提供封装了数据库二进制协议的服务端版本,用于完成对异构语言的支持。 目前先提供MySQL/PostgreSQL版本,它可以使用任何兼容MySQL/PostgreSQL协议的访问客户端(如:MySQL Command Client, MySQL Workbench, Navicat等)操作数据,对DBA更加友好。
- 向应用程序完全透明,可直接当做MySQL/PostgreSQL使用。
- 适用于任何兼容MySQL/PostgreSQL协议的的客户端
核心概念
逻辑表
水平拆分的数据库(表)的相同逻辑和数据结构表的总称。例:订单数据根据主键尾数拆分为10张表,分别是t_order_0到t_order_9,他们的逻辑表名为t_order
水平拆分的数据库(表)的相同逻辑和数据结构表的总称。例:订单数据根据主键尾数拆分为10张表,分别是t_order_0到t_order_9,他们的逻辑表名为t_order
真实表
在分片的数据库中真实存在的物理表。即上个示例中的t_order_0到t_order_9
在分片的数据库中真实存在的物理表。即上个示例中的t_order_0到t_order_9
数据节点
数据分片的最小单元。由数据源名称和数据表组成,例:ds_0.t_order_0
数据分片的最小单元。由数据源名称和数据表组成,例:ds_0.t_order_0
绑定表
分片规则一致的主表和子表。例如:t_order表和t_order_item表,均按照order_id分片,则此两张表互为绑定表关系。绑定表之间的多表关联查询不会出现笛卡尔积关联(都是直接通过order_id定位到具体真实表,不需要去把全部查询出来内存中处理),关联查询效率将大大提升。
分片规则一致的主表和子表。例如:t_order表和t_order_item表,均按照order_id分片,则此两张表互为绑定表关系。绑定表之间的多表关联查询不会出现笛卡尔积关联(都是直接通过order_id定位到具体真实表,不需要去把全部查询出来内存中处理),关联查询效率将大大提升。
广播表
指所有的分片数据源中都存在的表,表结构和表中的数据在每个数据库中均完全一致。适用于数据量不大且需要与海量数据的表进行关联查询的场景。字典表就是典型的场景
指所有的分片数据源中都存在的表,表结构和表中的数据在每个数据库中均完全一致。适用于数据量不大且需要与海量数据的表进行关联查询的场景。字典表就是典型的场景
ShardingSphere核心流程原理
数据分片-执行过程
解析引擎
解析过程分为词法解析和语法解析。 词法解析器用于将SQL拆解为不可再分的原子符号,称为Token。并根据不同数据库方言所提供的字典,将其归类为关键字,表达式,字面量和操作符。 再使用语法解析器将SQL转换为抽象语法树
SELECT id, name FROM t_user WHERE status = 'ACTIVE' AND age > 18
SELECT id, name FROM t_user WHERE status = 'ACTIVE' AND age > 18
路由引擎
内置的分片策略大致可分为尾数取模、哈希、范围、标签、时间等。 由用户方配置的分片策略则更加灵活,可以根据使用方需求定制复合分片策略
SQL改写
在分表的场景中,需要将分表配置中的逻辑表名称改写为路由之后所获取的真实表名称。
执行引擎
ShardingSphere并不是简单的将改写完的SQL提交到数据库执行。执行引擎的目标是自动化的平衡资源控制和执行效率。
例如他的连接模式分为内存限制模式(MEMORY_STRICTLY)和连接限制模式(CONNECTION_STRICTLY)。内存限制模式只关注一个数据库连接的处理数量,通常一张真实表一个数据库连接。而连接限制模式则只关注数据库连接的数量,较大的查询会进行串行操作
例如他的连接模式分为内存限制模式(MEMORY_STRICTLY)和连接限制模式(CONNECTION_STRICTLY)。内存限制模式只关注一个数据库连接的处理数量,通常一张真实表一个数据库连接。而连接限制模式则只关注数据库连接的数量,较大的查询会进行串行操作
结果归并
将从各个数据节点获取的多数据结果集,组合成为一个结果集并正确的返回至请求客户端,称为结果归并。
- 内存分组归并:将每个结果集数据全拉到内存进行归并;这个会对内存要求高,比如很多个app过来查询,每个1000条需要归并,很占内存;
- 流式分组归并:一条一条的从数据库中拉过来归并,归并完一条接着拉第二条过来再归并,注意这里并不是一条一条的从数据库中select,而是数据库那边准备好了,一条条拉过来;这个方式相当于 “以网卡换内存” ,频繁的从数据库中拉取,有1000条就拉1000次
xxl-job
xxl-job入门与设计原理
入门
特性/好处
支持通过Web页面对任务进行CRUD操作,操作简单
支持动态修改任务状态、启动/停止任务,以及终止运行中任务,即时生效
执行器会周期性自动注册任务, 调度中心将会自动发现注册的任务并触发执行。同时,也支持手动录入执行器地址
一旦有新执行器机器上线或者下线,下次调度时将会重新分配任务
“调度中心”通过DB锁保证集群分布式调度的一致性(当调度中心集群部署的时候), 一次任务调度只会触发一次执行
一个分布式的调度中心,支持任务分片,自动管理执行器,支持页面上对任务操作,可实时的进行启动与修改任务。提供简单的执行报表等
案例步骤
1.初始化“调度数据库”
2.下载源码,修改好配置,运行调度中心
3.项目中引入依赖,写配置文件,创建执行器配置类,创建任务
4.调度中心页面创建任务
原理详解
设计思想
将调度行为抽象形成“调度中心”公共平台,而平台自身并不承担业务逻辑,“调度中心”负责发起调度请求。
将任务抽象成分散的JobHandler,交由“执行器”统一管理,“执行器”负责接收调度请求并执行对应的JobHandler中业务逻辑。
因此,“调度”和“任务”两部分可以相互解耦,提高系统整体稳定性和扩展性
系统组成
调度模块(调度中心):负责管理调度信息,按照调度配置发出调度请求,自身不承担业务代码。调度系统与任务解耦,提高了系统可用性和稳定性,同时调度系统性能不再受限于任务模块;支持可视化、简单且动态的管理调度信息,包括任务新建,更新,删除,GLUE开发和任务报警等,所有上述操作都会实时生效,同时支持监控调度结果以及执行日志,支持执行器Failover
执行模块(执行器-我们的服务):负责接收调度请求并执行任务逻辑。任务模块专注于任务的执行等操作,开发和维护更加简单和高效;接收“调度中心”的执行请求、终止请求和日志请求等
任务HA(Failover)
执行器如若集群部署,调度中心将会感知到在线的所有执行器,如“127.0.0.1:9997, 127.0.0.1:9998, 127.0.0.1:9999”。
当任务”路由策略”选择”故障转移(FAILOVER)”时,当调度中心每次发起调度请求时,会按照顺序对执行器发出心跳检测请求,第一个检测为存活状态的执行器将会被选定并发送调度请求
当任务”路由策略”选择”故障转移(FAILOVER)”时,当调度中心每次发起调度请求时,会按照顺序对执行器发出心跳检测请求,第一个检测为存活状态的执行器将会被选定并发送调度请求
调度中心原理
启动注册监听线程
- 初始化registryOrRemoveThreadPool线程池:用于 注册或者移除的线程池,客户端调用api/registry或api/registryRemove接口时,会用这个线程池进行注册或注销
- 启动监听注册的线程registryMonitorThread: 清除心跳超过90s的注册信息,并且刷新分组注册信息
启动监控线程
- 初始化callbackThreadPool线程池:用于callback 回调的线程池,客户端调用api/callback接口时会使用这个线程池
- 启动监控线monitorThread:调度记录停留在 "运行中" 状态 超过10min,且对应执行器心跳注册 失败不在线,则将本地调度主动标记失败
启动日志统计和清除线程logrThread
- 日志记录刷新,刷新 最近三天的日志Report(即统计每天的失败、成功、运行次数等)
- 每天清除一次 失效过期的日志数据
启动任务调度(很重要!!主要靠这两个线程进行塞数据到时间轮,然后时间轮取数调度任务)
[非线程池,因为下发任务很快,执行器接收的时候放入待执行队列立马返回]
[非线程池,因为下发任务很快,执行器接收的时候放入待执行队列立马返回]
scheduleThread线程-取待执行任务数据入时间轮(塞数据)
- 第一步:用select for update 数据库作为分布式锁加锁,避免多个xxl-job admin调度器节点同时执行
- 第二步:预读数据,从数据库中读取当前截止到五秒后内会执行的job信息(任务表中有存入上次调度时间跟下次调度时间),并且读取分页大小为preReadCount=6000条数据
- 第三步:将当前时间与下次调度时间对比,有如下三种情况
- 第四步:更新数据库执行器信息,如trigger_last_time上次调度时间、trigger_next_time下次调度时间
- 第五步:提交数据库事务,释放数据库select for update排它锁
ringThread线程 -根据时间轮执行job任务 (取数据执行)
首先时间轮数据格式为:Map<Integer, List<Integer>> ringData = new ConcurrentHashMap<>()
- 第一步:获取当前所处的一分钟第几秒,然后for两次,第二次是为了重跑前面一个刻度没有被执行的的job list,避免前面的刻度遗漏了
- 第二步:执行触发器
- 第三步:清除当前刻度列表的数据
执行的过程中还会选择对应的策略,如下:
- 阻塞策略:串行、废弃后面、覆盖前面
- 路由策略:取第一个、取最后一个、最小分发、一致性hash、快速失败、LFU最不常用、LRU最近最少使用、随机、轮询
执行器原理
- 任务执行器根据配置的调度中心的地址,自动注册到调度中心。
- 达到任务触发条件,调度中心下发任务。执行器将任务放入待执行队列
- 执行器基于线程池执行任务,并把执行结果放入内存队列中、把执行日志写入日志文件中。
- 执行器的回调线程消费内存队列中的执行结果,主动上报给调度中心。
- 当用户在调度中心查看任务日志,调度中心请求任务执行器,任务执行器读取任务日志文件并返回日志详情。
任务注册, 任务自动发现
执行器注册摘除:执行器销毁时,将会主动上报调度中心并摘除对应的执行器机器信息,提高心跳注册的实时性;
- 初始化registryOrRemoveThreadPool线程池:用于 注册或者移除的线程池,客户端调用api/registry或api/registryRemove接口时,会用这个线程池进行注册或注销
- 启动监听注册的线程registryMonitorThread: 清除心跳超过90s的注册信息,并且刷新分组注册信息
自v1.5版本之后, 任务取消了”任务执行机器”属性, 改为通过任务注册和自动发现的方式, 动态获取远程执行器地址并执行。
- AppName: 每个执行器机器集群的唯一标示, 任务注册以 "执行器"(appName维度) 为最小粒度进行注册; 每个任务通过其绑定的执行器可感知对应的执行器机器列表;
- 注册表: 见"xxl_job_registry"表, "执行器" 在进行任务注册时将会周期性维护一条注册记录,即机器地址和AppName的绑定关系; "调度中心" 从而可以动态感知每个AppName在线的机器列表;
- 执行器注册: 任务注册Beat周期默认30s; 执行器以一倍Beat进行执行器注册, 调度中心以一倍Beat进行动态任务发现; 注册信息的失效时间为三倍Beat;
执行器注册摘除:执行器销毁时,将会主动上报调度中心并摘除对应的执行器机器信息,提高心跳注册的实时性;
为保证系统”轻量级”并且降低学习部署成本,没有采用Zookeeper作为注册中心,采用DB方式进行任务注册发现;(注册存表,发现查库)
xxl-job是通过一个中心式的调度平台,调度多个执行器执行任务,调度中心通过DB锁保证集群分布式调度的一致性,这样扩展执行器会增大DB的压力,但是如果实际上这里数据库只是负责任务的调度执行。但是如果没有大量的执行器的话和任务的情况,是不会造成数据库压力的。实际上大部分公司任务数,执行器并不多(虽然面试经常会问一些高并发的问题)
一致性问题
在集群部署时,多台调度器如何保证任务不会重复调用呢?
- 并发情况下: 通过mysql悲观锁实现分布式锁(for update语句);
- 任务阻塞或调度密集情况下: 结合 单机路由策略(如:第一台、一致性哈希) + 阻塞策略(如:单机串行、丢弃后续调度)来规避
过期处理策略
任务调度错过触发时间时的处理策略:
- 可能原因:服务重启;调度线程被阻塞,线程被耗尽;上次调度持续阻塞,下次调度被错过;
- 处理策略:
- 过期超5s:本次忽略,当前时间开始计算下次触发时间
- 过期5s内:立即触发一次,当前时间开始计算下次触发时间
elk
分布式搜索引擎(基础入门)
Lucene全文检索框架
什么是全文检索
全文检索是指:
- 通过一个程序扫描文本中的每一个单词,针对单词建立索引,并保存该单词在文本中的位置、以及出现的次数
- 用户查询时,通过之前建立好的索引来查询,将索引中单词对应的文本位置、出现的次数返回给用户,因为有了具体文本的位置,所以就可以将具体内容读取出来了
分词原理之倒排索引
假如,我们有一个站内搜索的功能,通过某个关键词来搜索相关的文章,那么这个关键词可能出现在标题中,也可能出现在文章内容中,那我们将会在创建或修改文章的时候,建立一个关键词与文章的对应关系表,这种,我们可以称之为倒排索引,因此倒排索引,也可称之为反向索引.如:
注:这里涉及中文分词的问题
注:这里涉及中文分词的问题
ElasticSearch
什么是ES
Elasticsearch是用Java开发并且是当前最流行的开源的企业级搜索引擎。能够达到实时搜索,稳定,可靠,快速,安装使用方便。
客户端支持Java、.NET(C#)、PHP、Python、Ruby等多种语言
客户端支持Java、.NET(C#)、PHP、Python、Ruby等多种语言
ES名词定义
索引:存在很多个,有点相当于mysql中的数据库概念
类型(type): es6.x只有一个type,之前可以建很多,es7.x就没有这个type了(废弃了),相当于mysql中的表概念
文档:docment;相当于mysql中表中的行数据
Field:相当于mysql中的列
类型(type): es6.x只有一个type,之前可以建很多,es7.x就没有这个type了(废弃了),相当于mysql中的表概念
文档:docment;相当于mysql中表中的行数据
Field:相当于mysql中的列
缺点:nosql 非关系型的,没有办法链接查询的,也就是跨索引查询
正向索引与倒排索引
正向索引
一个key对应一篇文档的形式就是我们所说的正向索引
倒排索引
倒排的意思就是我们把value对应成key,因为一个value是没有有办法得出确定的一篇文档的,可以得出很多。这就是倒排索引跟正向索引最大的不同点
打分排序
什么是打分排序
当我们根据关键字搜索后,可能存在很多个docment结果,这些docment的排序是需要进行排序的,它不是简单的根据一个字段排序,而且需要根据一定的算法得到匹配度
TFIDF打分
- TF:词频 。一篇doc中包含了多少这个词,包含越多表明越相关
- DF:文档频率。包含这个词的文档总数,比如 搜ElasticSearch你就去找 ElasticSearch在多少篇文档中出现了
- IDF = 1/DF:逆文档,DF取反 也就是 1/DF;如果包含该词的文档越少,也就是DF越小,IDF越大,则说明词对这篇文档重要性就越大。
TFIDF: TF*IDF 的主要思想是:如果某个词或短语在一篇文章中出现的频率TF高,并且在其他文章中很少出现,则认为此词或者短语具有很好的类别区分能力,排序就越前
ES的打分排序:BM25算法 tfNom
es集群
不同节点介绍
客户端节点
当主节点和数据节点配置都设置为false的时候,该节点只能处理路由请求,处理搜索,分发索引操作等,从本质上来说该客户节点表现为智能负载平衡器。
独立的客户端节点在一个比较大的集群中是非常有用的,他协调主节点和数据节点,客户端节点加入集群可以得到集群的状态,根据集群的状态可以直接路由请求
独立的客户端节点在一个比较大的集群中是非常有用的,他协调主节点和数据节点,客户端节点加入集群可以得到集群的状态,根据集群的状态可以直接路由请求
数据节点
数据节点主要是存储索引数据的节点,主要对文档进行增删改查操作,聚合操作等。数据节点对cpu,内存,io要求较高, 在优化的时候需要监控数据节点的状态,当资源不够的时候,需要在集群中添加新的节点。
主节点
主资格节点的主要职责是和集群操作相关的内容,如创建或删除索引,跟踪哪些节点是群集的一部分,并决定哪些分片分配给相关的节点。稳定的主节点对集群的健康是非常重要的,默认情况下任何一个集群中的节点都有可能被选为主节点,索引数据和搜索查询等操作会占用大量的cpu,内存,io资源,为了确保一个集群的稳定,分离主节点和数据节点是一个比较好的选择
在一个生产集群中我们可以对这些节点的职责进行划分,建议集群中设置3台以上的节点作为master节点,这些节点只负责成为主节点,维护整个集群的状态。再根据数据量设置一批data节点,这些节点只负责存储数据,后期提供建立索引和查询索引的服务,这样的话如果用户请求比较频繁,这些节点的压力也会比较大,所以在集群中建议再设置一批client节点(node.master: false node.data: false),这些节点只负责处理用户请求,实现请求转发,负载均衡等功能
ElasticSearch基础
分布式索引
- number_of_shards:分片数量,类似于数据库里面分库分表,一经定义不可更改。主要响应写操作
- number_of_replicas:副本数,用于备份分片的,和分片里面的数据保持一致,主要响应读操作,副本越多读取就越快。
分布式索引一定要注意分片数量不能更改,所以在创建的时候一定要预先估算好数据大小,一般在8CPU16G的机器上一个分片不要超过300g(不怕的话就500g,只要你索引数据结构优化的好,一般的是没问题)。索引会根据分片的配置来均匀的响应用户请求。如果调整了分片数那就要重建索引
Head集群分片结构
- 存在两个节点:【node-1、node-2】(主节点);
- 存在两个索引【test、test2】;
- test中存在3个分片【0、1、2】,test2中存在4个分片【0、1、2、3】;
- test跟test2都存在两个副本(每个分片都有副本,主分片是通过计算得来的并不是一定在主节点中)
为什么shards不能修改
因为我们采取的是分布式索引,假如我们有4篇文档id分别为:1,2,3,4。相信大家在数据库的分表中都知道有一个取模算法。用它来分配记录到对应的表中,其实es也采取的是这种思路。所以1、3 % 2 ==> 1那么就会在第二个分片上。2、4 % 2 ==> 0那么就会在第一个分片上。如果这时候你把分片数变了,很显然数据就不对了。所以分片数一旦变化需要重新全部建索引
集群中读写操作分析
写/删/改操作
如果从节点写会转发到主节点;
(1)客户端向Node1发送新建、索引或者删除请求。
(2)节点使用文档的_id确定文档属于分片0。找到分片0的主分片是在node3节点上,请求会被转发到Node3。
(3)Node 3在主分片上面执行请求。如果成功了,它将请求并行转发到 Node 1和 Node 2 的副本分片上。一旦过半的副本分片都报告成功, Node 3 将向协调节点报告成功,协调节点向客户端报告成功。
在客户端收到成功响应时,文档变更已经在主分片和所有副本分片执行完成,变更是安全的。
读操作
(1)客户端向Node1发送获取请求。
(2)节点使用文档的_id来确定文档属于分片0,分片0的数据在三个节点上都有。 在这种情况下,它会根据负载均衡策略将请求转发到其中一个,比如Node2 。
(3)Node2将文档返回给Node1然后将文档返回给客户端。
在处理读取请求时,协调结点在每次请求的时候都会通过轮询所有的副本分片来达到负载均衡。
在文档被检索时,已经被索引的文档可能已经存在于主分片上但是还没有复制到副本分片。 在这种情况下,副本分片可能会报告文档不存在,但是主分片可能成功返回文档。就存在数据读取不到,这没法避免
(2)节点使用文档的_id来确定文档属于分片0,分片0的数据在三个节点上都有。 在这种情况下,它会根据负载均衡策略将请求转发到其中一个,比如Node2 。
(3)Node2将文档返回给Node1然后将文档返回给客户端。
在处理读取请求时,协调结点在每次请求的时候都会通过轮询所有的副本分片来达到负载均衡。
在文档被检索时,已经被索引的文档可能已经存在于主分片上但是还没有复制到副本分片。 在这种情况下,副本分片可能会报告文档不存在,但是主分片可能成功返回文档。就存在数据读取不到,这没法避免
ElasticSearch与Lucene的关系
Lucene可以被认为是迄今为止最先进、性能最好的、功能最全的搜索引擎库(框架)
但是想要使用Lucene,必须使用Java来作为开发语言并将其直接集成到你的应用中,并且Lucene的配置及使用非常复杂,你需要深入了解检索的相关知识来理解它是如何工作的。
但是想要使用Lucene,必须使用Java来作为开发语言并将其直接集成到你的应用中,并且Lucene的配置及使用非常复杂,你需要深入了解检索的相关知识来理解它是如何工作的。
Lucene缺点:
1)只能在Java项目中使用,并且要以jar包的方式直接集成项目中.
2)使用非常复杂-创建索引和搜索索引代码繁杂
3)不支持集群环境-索引数据不同步(不支持大型项目)
4)索引数据如果太多就不行,索引库和应用所在同一个服务器,共同占用硬盘.共用空间少.
上述Lucene框架中的缺点,ES全部都能解决
ES vs Solr比较
1、Solr 利用 Zookeeper 进行分布式管理,而Elasticsearch 自身带有分布式协调管理功能。
2、Solr 支持更多格式的数据,比如JSON、XML、CSV,而 Elasticsearch 仅支持json文件格式。
3、Solr 在传统的搜索应用中表现好于 Elasticsearch,但在处理实时搜索应用时效率明显低于 Elasticsearch。
4、Solr 是传统搜索应用的有力解决方案,但 Elasticsearch更适用于新兴的实时搜索应用。
Elasticsearch架构原理与搜索技术深入
主节点选举过程
节点类型
在Elasticsearch中,每个节点可以有多个角色,节点既可以是候选主节点,也可以是数据节点
节点的角色配置在配置文件/config/elasticsearch.yml中设置即可,配置参数如下所示在Elasticsearch中,默认都为true
node.master: true //是否为候选主节点
node.data: true //是否为数据节点
数据节点 负责 数据的存储相关的操作,如对数据进行增、删、改、查和聚合等
数据节点 往往 对服务器的配置要求比较高,特别是对CPU、内存和I/O的需求很大
数据节点 梳理 通常随着 集群的扩大而弹性增加,以便保持Elasticsearch服务的高性能和高可用
数据节点 往往 对服务器的配置要求比较高,特别是对CPU、内存和I/O的需求很大
数据节点 梳理 通常随着 集群的扩大而弹性增加,以便保持Elasticsearch服务的高性能和高可用
候选主节点 是 被选举为主节点 的 节点
在集群中,只有候选主节点 才有 选举权和被选举权,其他节点不参与选举工作
在集群中,只有候选主节点 才有 选举权和被选举权,其他节点不参与选举工作
一旦候选主节点 被选举为 主节点,则主节点 就要负责 创建索引、删除索引、追踪集群中节点的状态,以及 跟踪 哪些节点 是 群集的一部分,并决定 将哪些分片 分配给 相关的节点等
一个Elasticsearch集群中,只有一个Master节点。在生产环境中,内存可以相对小一点,但机器要稳定。
数据写入、数据检索,大部分Elasticsearch的压力都在DataNode节点上,在生产环境中,内存最好配置大一些
配置单播模式后,集群构建及主节点选举过程如下:
节点启动后 先执行 ping命令(这里提及的ping命令不是Linux环境用的ping命令,而是Elasticsearch的一个RPC命令),如果discovery.zen.ping.unicast.hosts有设置,则ping设置中的host;否则尝试ping localhost的几个端口
ping命令的返回结果 会包含 该节点的基本信息 及 该节点认为的主节点
在选举开始时,主节点 先从 各节点认为的master中选,选举规则比较简单,即按照ID的字典序排序,取第一个,如果各节点 都没有认为的master,则从所有节点中选择,规则同上
需要注意的是,这里有个 集群中 节点梳理 最小值限制条件,即discovery.zen.minimum_master_nodes,如果节点数达不到最小值的限制,则循环上述过程,直到节点数超过最小限制值,才可以开始选举
最后选举出一个主节点,如果只有一个本地节点,则主节点就是它自己
如果当前节点是主节点,则开始等待节点数达到minimum_master_nodes,再提供服务
如果当前节点不是主节点,则尝试加入主节点所在集群
分片和副本机制
分片(Shard)
Elasticsearch是一个分布式的搜索引擎,索引的数据也是分成若干部分,分布在不同的服务器节点中
分布在不同服务器节点中的索引数据,就是分片(Shard)。Elasticsearch会自动管理分片,如果发现分片分布不均衡,就会自动迁移
一个索引(index)由多个shard(分片)组成,而分片是分布在不同的服务器上的
副本
为了对Elasticsearch的分片进行容错,假设某个节点不可用,会导致整个索引库都将不可用。所以,需要对分片进行副本容错。每一个分片都会有对应的副本。
在Elasticsearch中,默认创建的索引为1个分片、每个分片有1个主分片和1个副本分片。
每个分片都会有一个Primary Shard(主分片),也会有若干个Replica Shard(副本分片)
Primary Shard和Replica Shard不在同一个节点上
Elasticsearch重要工作流程
Elasticsearch文档写入原理
1.选择任意一个DataNode发送请求,例如:node2。此时,node2就成为一个coordinating node(协调节点)
2.计算得到文档要写入的分片位置
`shard = hash(routing) % number_of_primary_shards`
routing 是一个可变值,默认是文档的 _id
3.coordinating node会进行路由,将请求转发给对应的primary shard所在的DataNode(假设primary shard在node1、replica shard在node2)
4.node1节点上的Primary Shard处理请求,写入数据到索引库中,并将数据同步到Replica shard
5.Primary Shard和Replica Shard都保存好了文档,返回client
2.计算得到文档要写入的分片位置
`shard = hash(routing) % number_of_primary_shards`
routing 是一个可变值,默认是文档的 _id
3.coordinating node会进行路由,将请求转发给对应的primary shard所在的DataNode(假设primary shard在node1、replica shard在node2)
4.node1节点上的Primary Shard处理请求,写入数据到索引库中,并将数据同步到Replica shard
5.Primary Shard和Replica Shard都保存好了文档,返回client
Elasticsearch检索原理
1.client发起查询请求,某个DataNode接收到请求,该DataNode就会成为协调节点(Coordinating Node)
2.协调节点(Coordinating Node)将查询请求广播到每一个数据节点,这些数据节点的分片会处理该查询请求
3.每个分片进行数据查询,将符合条件的数据放在一个优先队列中,并将这些数据的文档ID、节点信息、分片信息返回给协调节点
4.协调节点将所有的结果进行汇总,并进行全局排序
5. 协调节点向包含这些文档ID的分片发送get请求,对应的分片将文档数据返回给协调节点,最后协调节点将数据返回给客户端
2.协调节点(Coordinating Node)将查询请求广播到每一个数据节点,这些数据节点的分片会处理该查询请求
3.每个分片进行数据查询,将符合条件的数据放在一个优先队列中,并将这些数据的文档ID、节点信息、分片信息返回给协调节点
4.协调节点将所有的结果进行汇总,并进行全局排序
5. 协调节点向包含这些文档ID的分片发送get请求,对应的分片将文档数据返回给协调节点,最后协调节点将数据返回给客户端
Elasticsearch准实时索引实现
溢写到文件系统缓存
当数据写入到ES分片时,会首先写入到内存中,然后通过内存的buffer生成一个segment,并刷到文件系统缓存(pageCache)中,数据可以被检索(注意不是直接刷到磁盘)
ES中默认1秒,refresh一次
ES中默认1秒,refresh一次
写translog保障容错
在写入到内存中的同时,也会记录translog日志,在refresh期间出现异常,会根据translog来进行数据恢复
等到文件系统缓存中的segment数据都刷到磁盘中,清空translog文件
等到文件系统缓存中的segment数据都刷到磁盘中,清空translog文件
flush到磁盘
ES默认每隔30分钟会将文件系统缓存的数据刷入到磁盘
segment合并
Segment太多时,ES定期会将多个segment合并成为大的segment,减少索引查询时IO开销,此阶段ES会真正的物理删除(之前执行过的delete的数据)
使用multi_match简化dis_max+tie_breaker
ES中相同结果的搜索也可以使用不同的语法语句来实现。不需要特别关注,只要能够实现搜索,就是完成任务!
高级搜索技术深入
手工控制搜索结果精准度
match 的底层转换
其实在ES中,执行match搜索的时候,ES底层通常都会对搜索条件进行底层转换,来实现最终的搜索结果
建议,如果不怕麻烦,尽量使用转换后的语法执行搜索,效率更高。
如果开发周期短,工作量大,使用简化的写法
如果开发周期短,工作量大,使用简化的写法
boost权重控制
搜索document中remark字段中包含java的数据,如果remark中包含developer或architect,则包含architect的document优先显示。(就是将architect数据匹配时的相关度分数增加)。
一般用于搜索时相关度排序使用。如:电商中的综合排序。将一个商品的销量,广告投放,评价值,库存,单价比较综合排序。在上述的排序元素中,广告投放权重最高,库存权重最低。
基于dis_max实现best fields策略进行多字段搜索
best fields策略: 搜索的document中的某一个field,尽可能多的匹配搜索条件。与之相反的是,尽可能多的字段匹配到搜索条件(most fields策略)。如百度搜索使用这种策略。
优点:精确匹配的数据可以尽可能的排列在最前端,且可以通过minimum_should_match来去除长尾数据,避免长尾数据字段对排序结果的影响。
长尾数据比如说我们搜索4个关键词,但很多文档只匹配1个,也显示出来了,这些文档其实不是我们想要的
缺点:相对排序不均匀。
dis_max语法: 直接获取搜索的多条件中的,单条件query相关度分数最高的数据,以这个数据做相关度排序。
优点:精确匹配的数据可以尽可能的排列在最前端,且可以通过minimum_should_match来去除长尾数据,避免长尾数据字段对排序结果的影响。
长尾数据比如说我们搜索4个关键词,但很多文档只匹配1个,也显示出来了,这些文档其实不是我们想要的
缺点:相对排序不均匀。
dis_max语法: 直接获取搜索的多条件中的,单条件query相关度分数最高的数据,以这个数据做相关度排序。
基于tie_breaker参数优化dis_max搜索效果
dis_max是将多个搜索query条件中相关度分数最高的用于结果排序,忽略其他query分数,在某些情况下,可能还需要其他query条件中的相关度介入最终的结果排序,这个时候可以使用tie_breaker参数来优化dis_max搜索。tie_breaker参数代表的含义是:将其他query搜索条件的相关度分数乘以参数值,再参与到结果排序中。如果不定义此参数,相当于参数值为0。所以其他query条件的相关度分数被忽略。
cross fields搜索
cross fields : 一个唯一的标识,分部在多个fields中,使用这种唯一标识搜索数据就称为cross fields搜索。如:人名可以分为姓和名,地址可以分为省、市、区县、街道等。那么使用人名或地址来搜索document,就称为cross fields搜索。
实现这种搜索,一般都是使用most fields搜索策略。因为这就不是一个field的问题。
Cross fields搜索策略,是从多个字段中搜索条件数据。默认情况下,和most fields搜索的逻辑是一致的,计算相关度分数是和best fields策略一致的。一般来说,如果使用cross fields搜索策略,那么都会携带一个额外的参数operator。用来标记搜索条件如何在多个字段中匹配。
实现这种搜索,一般都是使用most fields搜索策略。因为这就不是一个field的问题。
Cross fields搜索策略,是从多个字段中搜索条件数据。默认情况下,和most fields搜索的逻辑是一致的,计算相关度分数是和best fields策略一致的。一般来说,如果使用cross fields搜索策略,那么都会携带一个额外的参数operator。用来标记搜索条件如何在多个字段中匹配。
most field策略问题:most fields策略是尽可能匹配更多的字段,所以会导致精确搜索结果排序问题。又因为cross fields搜索,不能使用minimum_should_match来去除长尾数据。
所以在使用most fields和cross fields策略搜索数据的时候,都有不同的缺陷。所以商业项目开发中,都推荐使用best fields策略实现搜索
所以在使用most fields和cross fields策略搜索数据的时候,都有不同的缺陷。所以商业项目开发中,都推荐使用best fields策略实现搜索
copy_to组合fields
京东中,如果在搜索框中输入“手机”,点击搜索,那么是在商品的类型名称、商品的名称、商品的卖点、商品的描述等字段中,哪一个字段内进行数据的匹配?如果使用某一个字段做搜索不合适,那么使用_all做搜索是否合适?也不合适,因为_all字段中可能包含图片,价格等字段。
假设,有一个字段,其中的内容包括(但不限于):商品类型名称、商品名称、商品卖点等字段的数据内容。是否可以在这个特殊的字段上进行数据搜索匹配?
假设,有一个字段,其中的内容包括(但不限于):商品类型名称、商品名称、商品卖点等字段的数据内容。是否可以在这个特殊的字段上进行数据搜索匹配?
copy_to : 就是将多个字段,复制到一个字段中,实现一个多字段组合。copy_to可以解决cross fields搜索问题,在商业项目中,也用于解决搜索条件默认字段问题。
如果需要使用copy_to语法,则需要在定义index的时候,手工指定mapping映射策略。
如果需要使用copy_to语法,则需要在定义index的时候,手工指定mapping映射策略。
近似匹配
前文都是精确匹配。如doc中有数据java assistant,那么搜索jave是搜索不到数据的。因为jave单词在doc中是不存在的。
如果需要的结果是有特殊要求,如:hello world必须是一个完整的短语,不可分割;或document中的field内,包含的hello和world单词,且两个单词之间离的越近,相关度分数越高。那么这种特殊要求的搜索就是近似搜索。包括hell搜索条件在hello world数据中搜索,包括h搜索提示等都数据近似搜索的一部分。
如何上述特殊要求的搜索,使用match搜索语法就无法实现了。
如何上述特殊要求的搜索,使用match搜索语法就无法实现了。
match phrase
短语搜索。就是搜索条件不分词。代表搜索条件不可分割。
如果hello world是一个不可分割的短语,我们可以使用前文学过的短语搜索match phrase来实现。语法如下:
GET _search
{
"query": {
"match_phrase": {
"remark": "java assistant"
}
}
match phrase原理 -- term position
ES是如何实现match phrase短语搜索的?其实在ES中,使用match phrase做搜索的时候,也是和match类似,首先对搜索条件进行分词-analyze。将搜索条件拆分成hello和world。既然是分词后再搜索,ES是如何实现短语搜索的?
这里涉及到了倒排索引的建立过程。在倒排索引建立的时候,ES会先对document数据进行分词,如:
GET _analyze
{
"text": "hello world, java spark",
"analyzer": "standard"
这里涉及到了倒排索引的建立过程。在倒排索引建立的时候,ES会先对document数据进行分词,如:
GET _analyze
{
"text": "hello world, java spark",
"analyzer": "standard"
从上述结果中,可以看到。ES在做分词的时候,除了将数据切分外,还会保留一个position。position代表的是这个词在整个数据中的下标。当ES执行match phrase搜索的时候,首先将搜索条件hello world分词为hello和world。然后在倒排索引中检索数据,如果hello和world都在某个document的某个field出现时,那么检查这两个匹配到的单词的position是否是连续的,如果是连续的,代表匹配成功,如果是不连续的,则匹配失败
match phrase搜索参数 -- slop
在做搜索操作的是,如果搜索参数是hello spark。而ES中存储的数据是hello world, java spark。那么使用match phrase则无法搜索到。在这个时候,可以使用match来解决这个问题。但是,当我们需要在搜索的结果中,做一个特殊的要求:hello和spark两个单词距离越近,document在结果集合中排序越靠前,这个时候再使用match则未必能得到想要的结果。
ES的搜索中,对match phrase提供了参数slop。slop代表match phrase短语搜索的时候,单词最多移动多少次,可以实现数据匹配。在所有匹配结果中,多个单词距离越近,相关度评分越高,排序越靠前。
这种使用slop参数的match phrase搜索,就称为近似匹配(proximity search)
这种使用slop参数的match phrase搜索,就称为近似匹配(proximity search)
如:
数据为: hello world, java spark
搜索为: match phrase : hello spark。
slop为: 3 (代表单词最多移动3次。)
执行短语搜索的时候,将条件hello spark分词为hello和spark两个单词。并且连续。
hello spark
接下来,可以根据slop参数执行单词的移动。
下标 : 0 1 2 3
doc : hello world java spark
搜索 : hello spark
移动1: hello spark
移动2: hello spark
匹配成功,不需要移动第三次即可匹配。
数据为: hello world, java spark
搜索为: match phrase : hello spark。
slop为: 3 (代表单词最多移动3次。)
执行短语搜索的时候,将条件hello spark分词为hello和spark两个单词。并且连续。
hello spark
接下来,可以根据slop参数执行单词的移动。
下标 : 0 1 2 3
doc : hello world java spark
搜索 : hello spark
移动1: hello spark
移动2: hello spark
匹配成功,不需要移动第三次即可匹配。
如果:
数据为: hello world, java spark
搜索为: match phrase : spark hello。
slop为: 5 (代表单词最多移动5次。)
执行短语搜索的时候,将条件hello spark分词为hello和spark两个单词。并且连续。
spark hello
接下来,可以根据slop参数执行单词的移动。
下标 : 0 1 2 3
doc : hello world java spark
搜索 : spark hello
移动1: spark/hello
移动2: hello spark
移动3: hello spark
移动4: hello spark
匹配成功,不需要移动第五次即可匹配。
如果当slop移动次数使用完毕,还没有匹配成功,则无搜索结果。如果使用中文分词,则移动次数更加复杂,因为中文词语有重叠情况,很难计算具体次数,需要多次尝试才行。
经验分享
使用match和proximity search实现召回率和精准度平衡。
召回率:召回率就是搜索结果比率,如:索引A中有100个document,搜索时返回多少个document,就是召回率(recall)。
精准度:就是搜索结果的准确率,如:搜索条件为hello java,在搜索结果中尽可能让短语匹配和hello java离的近的结果排序靠前,就是精准度(precision)。
如果在搜索的时候,只使用match phrase语法,会导致召回率底下,因为搜索结果中必须包含短语(包括proximity search)。
如果在搜索的时候,只使用match语法,会导致精准度底下,因为搜索结果排序是根据相关度分数算法计算得到。
那么如果需要在结果中兼顾召回率和精准度的时候,就需要将match和proximity search混合使用,来得到搜索结果。
其他搜索
前缀搜索 prefix search
使用前缀匹配实现搜索能力。通常针对keyword类型字段,也就是不分词的字段。
注意:针对前缀搜索,是对keyword类型字段而言。而keyword类型字段数据大小写敏感。
前缀搜索效率比较低。前缀搜索不会计算相关度分数。前缀越短,效率越低。如果使用前缀搜索,建议使用长前缀。因为前缀搜索需要扫描完整的索引内容,所以前缀越长,相对效率越高。
通配符搜索
ES中也有通配符。但是和java还有数据库不太一样。通配符可以在倒排索引中使用,也可以在keyword类型字段中使用。
常用通配符:
? - 一个任意字符
* - 0~n个任意字符
常用通配符:
? - 一个任意字符
* - 0~n个任意字符
性能也很低,也是需要扫描完整的索引。不推荐使用。
正则搜索
ES支持正则表达式。可以在倒排索引或keyword类型字段中使用。
常用符号:
[] - 范围,如: [0-9]是0~9的范围数字
. - 一个字符
+ - 前面的表达式可以出现多次
常用符号:
[] - 范围,如: [0-9]是0~9的范围数字
. - 一个字符
+ - 前面的表达式可以出现多次
性能也很低,需要扫描完整索引。
搜索推荐(搜索提示)
搜索推荐: search as your type, 搜索提示。如:索引中有若干数据以“hello”开头,那么在输入hello的时候,推荐相关信息。(类似百度输入框)
其原理和match phrase类似,是先使用match匹配term数据(java),然后在指定的slop移动次数范围内,前缀匹配(s),max_expansions是用于指定prefix最多匹配多少个term(单词),超过这个数量就不再匹配了。
这种语法的限制是,只有最后一个term会执行前缀搜索。
执行性能很差,毕竟最后一个term是需要扫描所有符合slop要求的倒排索引的term。
因为效率较低,如果必须使用,则一定要使用参数max_expansions。
这种语法的限制是,只有最后一个term会执行前缀搜索。
执行性能很差,毕竟最后一个term是需要扫描所有符合slop要求的倒排索引的term。
因为效率较低,如果必须使用,则一定要使用参数max_expansions。
fuzzy模糊搜索技术
搜索的时候,可能搜索条件文本输入错误,如:hello world -> hello word。这种拼写错误还是很常见的。fuzzy技术就是用于解决错误拼写的(在英文中很有效,在中文中几乎无效。)。其中fuzziness代表value的值word可以修改多少个字母来进行拼写错误的纠正(修改字母的数量包含字母变更,增加或减少字母。)。f代表要搜索的字段名称。
灵魂几问
es中不需要打分的情况排序是怎么实现的,毕竟es数据会分片,岂不是需要把所有数据都拿到其中一台来再排序
在 Elasticsearch 中,不需要打分的情况下排序通常是通过使用 Field Data Cache 来实现的。Field Data Cache 是 Elasticsearch 中用于缓存字段数据的一种高速缓存,通常用于在对索引进行筛选、聚合和排序等操作时,提供更快的查询性能。
在对数据排序时,Elasticsearch 将会把查询请求发送到各个节点,每个节点会对本地的数据分片进行排序,然后再将各个分片的排序结果合并起来,并返回给客户端。在分片排序前,需要先从 Field Data Cache 中加载相应的字段数据。
通过 Field Data Cache 缓存字段数据,Elasticsearch 可以在每个节点上保有所需的全部数据,避免了需要把所有数据都拿到其中一台主机上的情况。这样做的好处是,可以减少网络开销,提高查询性能,并提供更好的可伸缩性。
需要注意的是,在使用 Field Data Cache 进行数据排序时,需要注意每个节点可用的内存 size,缓存的 size 可能会导致内存耗尽,同时也要注意可用磁盘空间的 size。如果需要大量排序或者进行高度并发的排序操作,可能需要进行更多的配置和调整,例如增加节点的数量、使用分片、调整索引的副本数量等,以达到更好的查询性能和可伸缩性。
es的写入数据性能相比mysql如何,请从单个插入跟批量插入分析
Elasticsearch 与 MySQL 在写入数据性能方面相比,在某些情况下可能有着不同的表现。具体而言,在单个插入和批量插入等操作中,它们的性能特征如下:
单个插入:在 Elasticsearch 中,单个插入数据的性能通常比 MySQL 更好,因为 Elasticsearch 能够更好地处理索引和文档等大型数据的写入,支持部分文档的更新等操作,而且在索引、路由分片和副本方面的设计相对复杂。因此,单个插入文档在 Elasticsearch 中执行很快。
批量插入:对于批量插入数据,Elasticsearch 的表现通常比 MySQL 更优,特别是对于大量的数据插入。这是因为 Elasticsearch 通过使用批量操作 API 和 bulk 等指令,能够对数据进行高效的处理,同时支持基于刷新策略、索引缓存和文档预处理等策略,能够很好地处理大规模数据的写入。
总之,Elasticsearch 与 MySQL 在写入数据性能方面相比,具有各自的特点和优势,需要根据实际情况进行选择和使用。对于单个插入,Elasticsearch 更适用于大规模文档的写入,而 MySQL 更适用于 OLTP 等小规模事务处理;对于批量插入,Elasticsearch 则更适用于处理海量数据批量写入。同时也需要注意,写入性能的表现还受到很多其他的因素的影响,例如硬件配置、网络带宽和数据本身的特点等。
es中插入的时候,数据同步给副节点是异步操作吗
在 Elasticsearch 中,插入数据时数据同步给副本节点是一个异步过程。
当主节点接收到写入请求后,数据将首先写入到本地主分片,并将更新操作广播到其他副本节点。其他副本节点会异步地从主节点获取更新后的文档并进行同步,这个过程会在后台发生。具体而言,Elasticsearch 使用一种先进的基于版本的控制机制,即利用版本号来维护数据的一致性和同步性。在更新主分片后,会将新版本的文档信息发送给副本节点(注意,不是整个文档,只是版本号和差量),然后副本节点根据文档版本的匹配情况来确定是否需要更新自己的本地分片。
在文档同步过程中,Elasticsearch 会尽量保证主副本之间的数据一致性,但是由于文档同步过程的异步性,可能存在一定时间内主副本之间的数据不一致。这种情况下,在主分片产生故障时,Elasticsearch 会利用副本分片来升级新的主分片,并尽可能保持数据的一致性。
需要注意的是,Elasticsearch 中的文档同步过程可能受到网络带宽、磁盘 I/O 和 CPU 等因素的影响,可能会导致主副本之间的数据同步存在一定的延迟。因此,在数据的更新和查询中,需要考虑主副本间数据一致性的问题,以及延迟的影响。为了提高节点的可用性和数据一致性,建议使用具有足够副本拷贝数量的分片,并考虑如何设计合适的业务容错和读写流程。
为什么它不直接发送数据给副本节点,而是发送版本号跟差量
Elasticsearch 通过发送版本号和差量来同步数据,主要是为了减少数据传输量和提高同步效率。另外,这种基于版本号和差量的同步方式也是很多 NoSQL 数据库采用的同步方式。
具体而言,Elasticsearch 中每个文档都有一个版本号(version),该版本号由主节点进行分配,并随着每次更新而增加。每个节点中都会缓存当前文档的版本号和文档的最新状态。当主节点更新文档时,只需将新的版本号和文档差量(包括新字段或字段变更信息)发送给副本节点,而不是将整个文档重新发送给副本节点,以避免数据冗余和影响网络传输速度。
通过发送版本号和差量,Elasticsearch 可以实现更快的主副本数据同步,同时减少网络传输带宽和数据处理开销。需要注意的是,由于文档同步过程的异步性和复杂性,可能会导致主副本之间的数据不一致性和延迟,需要注意如何保证数据的一致性。在实际的应用中需要根据业务需要,进行适当的主副本负载均衡、节点容错和调整等策略,以实现更高效和稳定的数据同步和查询。
主副本同步的异步性体现在哪 具体点
主副本同步的异步性,在 Elasticsearch 中主要体现在以下几个方面:
数据同步过程是一种异步的时间延迟操作。Elasticsearch 在更新文档后,会在后台异步地将数据同步给副本分片。副本分片同步的时间会受到网络延迟、磁盘 I/O 和 CPU 等因素的影响,不同副本之间的数据同步可能存在一定的时间差,因此在某个时间点副本上的数据可能并不是最新的。
数据同步的实现方式是基于版本号和差量,即将新的版本号和只包含变更信息的差量同步给副本节点,而不是整个文档。这种实现方式可以减少网络传输开销和数据冗余,但也意味着主副本之间的文档同步在实现上是异步的,副本之间的数据不存在立即完全一致的保障。
同步的具体实现过程可能涉及到多个节点之间的交互和协作。当主分片更新后,会将更新后的文档版本号和差量发送给副本节点,然后副本节点自己根据自己本地记录的文档版本号来匹配更新,如果匹配成功,则将差量应用到本地的文档记录上。
总之,主副本同步的一些异步特性是 Elasticsearch 的基础特性之一,充分利用异步分布式架构来进行节点间的数据同步以及副本自愈,并通过诸如版本号、记录变更量等同步策略,采用一种深度合理协议来解决数据同步速度、一致性以及死锁等问题,并满足在不同场景下数据一致性的需求。
插入响应客户端后同步给副本节点还是其它时机
在 Elasticsearch 中,插入操作所涉及的主副本同步时机有两个:
同步刷写(Synced Flush):默认情况下,Elasticsearch 会将一定数量的操作累积到一个内存缓冲区中,等到缓冲区达到指定的大小后,再进行刷写操作,并同步数据到所有副本分片。这个刷写操作是异步的,需要一定时间才能完成。
强制刷写(Forced Flush):如果需要立即同步数据到所有副本分片,可以使用强制刷写操作,即通过在插入数据后调用 flush API 强制对缓冲区进行刷写操作。该操作会强制将所有操作缓冲区中的数据写入磁盘,并同步给所有副本分片。该操作会阻塞直到所有的操作都同步成功时才会停止,因此是一种同步插入的方法。
默认情况下,Elasticsearch 的插入操作会采用同步刷写机制,可以通过设置 index.translog.durability 配置项来调整刷写操作的时间点并设置刷写的耐久性。此外,如果要确保插入操作在同步给所有副本之后返回客户端,可以使用异步操作方式,在文档的 id 地址中添加 ?refresh=wait_for 参数。
需要注意的是,在实际应用中,由于主副本同步操作的异步特性和网络延迟等原因,可能会导致主副本之间存在一定的数据延迟和不一致性,因此需要针对具体应用场景,在主副本容错、节点负载均衡方面进行充分考虑和调整。
那它的数据版本号具体有什么用
在 Elasticsearch 中,每个文档都有一个版本号(version),该版本号由主节点进行分配,并随着每次更新而增加。数据版本号具体有以下两个作用。
用于控制数据一致性:通过版本号,Elasticsearch 可以有效地控制文档的更新版本,确保数据在分布式环境中的一致性。例如,当数据更新后,Elasticsearch 会分配一个新的版本号,并通过一定的同步机制,将新版本号的文档数据广播给所有副本节点,以保证数据的一致性和可靠性。
用于支持乐观锁控制:在 Elasticsearch 中,文档的版本号还可以支持乐观锁控制。在进行数据操作时,可以加上版本号作为条件验证,如果版本号匹配,则表示允许进行数据更新,如果版本号不匹配,则表示数据已经过期,需要重新获取最新数据后重新操作。这种乐观锁控制方式可以避免对数据进行锁定,同时也可以帮助保证数据的一致性。
总之,在 Elasticsearch 中,数据版本号是很重要的一种功能,通过版本号,可以使得 Elasticsearch 应用于分布式的环境下,能够更好地管理数据更新,并保证数据的一致性和可靠性。在实际的数据应用中,常常使用数据版本号来进行并发控制和数据同步,以达到更高效和稳定的数据管理。
版本号为何能保证数据的一致性
在 Elasticsearch 中,文档的版本号可以有效保证数据在分布式环境下的一致性,具体原因如下:
版本号允许落后更新:版本号在更新时会隔离老版本的数据,因此如果数据节点 A 的版本号比数据节点 B 落后,那么 B 可以从 A 获取新数据,从而保证数据的一致性。
冲突检测机制:如果多个节点更新了同一份数据,由于每个节点都有自己的版本号,就会导致数据的冲突。Elasticsearch 的数据冲突处理机制会对比不同版本号的文档内容,如果发现更新的文档冲突,则会标记为失败。然后,开发人员可以通过实现冲突解决逻辑来解决冲突。
乐观锁机制:Elasticsearch 使用乐观锁机制来实现数据的版本控制。在进行数据操作时,可以加上版本号作为条件验证,如果版本号匹配,则表示允许进行数据更新,如果版本号不匹配,则表示数据已经过期,需要重新获取最新数据后重新操作。这种乐观锁控制方式可以避免对数据进行加锁的操作,同时保证数据的一致性。
总之,通过版本号机制,Elasticsearch 保证了在分布式环境下,数据的一致性和可靠性。在应用中,开发人员可以根据实际的情况进行数据版本号的控制,以保证多线程之间对数据的修改不会冲突,并避免数据的更新操作造成数据的不一致等问题。
es的可用性如何,对比mysql
Elasticsearch 和 MySQL 在可用性方面都有着各自的特点和优劣。在以下方面对比它们的可用性:
分布式架构:Elasticsearch 采用分布式架构,运行在多个节点上,可以通过副本、分片和负载均衡等技术来保证数据的高可用性。而 MySQL 默认情况下只运行在单个节点上,需要通过多主复制、主从复制、双机热备等方式来实现数据备份和容错。
数据备份:Elasticsearch 原生支持分布式数据备份和副本节点,因此在数据丢失或节点故障时可以自动实现故障转移和数据恢复。MySQL 则需要使用主从复制或双机热备等技术实现数据备份和容错,相对来说更加复杂。
故障恢复:Elasticsearch 支持自动故障恢复和自动故障转移,可以快速恢复到故障前的状态。MySQL 则需要人工干预并手动处理故障,需要更多的运维成本和人力资源。
配置调整:Elasticsearch 支持动态配置调整,以适应不断变化的业务需求和负载情况。而 MySQL 则需要手动修改配置文件或者重启实例来实现配置调整。
总之,Elasticsearch 和 MySQL 在可用性方面都有着自身的优劣。对于需要分布式存储和处理的海量数据场景,Elasticsearch 更适用于快捷实现海量数据的实时可视化,并且通过其集群技术及时提供故障转移和数据备份等支持;而 MySQL 则更适用于小型分布式团队或企业,对于单一节点的数据处理拥有更高的数据一致性和可用性。可以根据实际需求和场景进行选择和使用。
设计模式
创建型--工厂、简单工厂、抽象工厂
简单工厂模式
定义一个用于创建对象的接口,让子类决定实例化哪一个类。FactoryMethod 使得一个类的实例化延迟到子类
工厂模式
工厂模式(Factory Pattern)是 Java 中最常用的设计模式之一。这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。在工厂模式中,我们在创建对象时不会对客户端暴露创建逻辑,并且是通过使用一个共同的接口 来指向新创建的对象
不同Factory创建不同的对象
抽象工厂模式
提供一个创建一系列相关或互相依赖对象的接口,而无需指定它们具体的类
工厂模式、简单工厂模式、抽象工厂模式的区别
抽象工厂是可以生产多个产品的,例如 MysqlDataBaseUtils 里可以生产 IConnection以及 ICommand两个产品,而这两个产品又是属于一个系列的,因为它们都是属于MySQL数据库的表。而工厂方法模式则只能生产一个产品,例如之前的 ShapeFactory里就只可以生产一个 Shape产品。
工厂模式:不同Factory创建不同的对象
简单工厂模式:同一个Factory根据不同类型创建不同的对象
抽象工厂模式:同一个Factory能生成多个产品
简单工厂模式:同一个Factory根据不同类型创建不同的对象
抽象工厂模式:同一个Factory能生成多个产品
创建型--单例模式
定义:保证一个类只有一个实例,并且提供一个全局访问点;
场景:重量级的对象,不需要多个实例,如线程池,数据库连接池
场景:重量级的对象,不需要多个实例,如线程池,数据库连接池
懒汉模式实现单例
延迟加载,只有在真正使用的时候,才开始实例化。
初始实现版本
线程安全问题:如果当多个线程同时进入了if(instance==null)判断里面,这时候就会创建多个对象;可通过使用双层检查加锁优化(Double Check Lock(DCL))
双层检查加锁优化版本
指令重排问题:编译器(JIT),CPU 有可能对指令进行重排序,多线程的情况下导致使用到尚未初始化 的实例,可以通过添加volatile 关键字进行修饰, 对于volatile 修饰的字段,可以防止指令重排
关于指令重排讲解:指令重排在单线程时是没有关系的,只有在多线程的情况下才会产生我们不想要的结果。我们都知道java代码会编译成一系列的字节码指令,然后才能在cpu中执行,当然这些指令进入cpu是有顺序的,但执行中就未必了,由于cpu可能对指令进行重排序(当然这里cpu也不是乱重排序,编译器会对不存在数据依赖的指令进行重排以提高整体程序的执行速度)。比如:这里的instance = new LazySingleton();操作,就存在1-new(堆中开辟空间)、2-invokespecial(初始化对象)、3-astore_1(将instance指向堆中的地址)这些指令,cpu可能会变成1-new(堆中开辟空间)、3-astore_1(将instance指向堆中的地址)、2-invokespecial(初始化对象),这时候相对其他的线程是不可见的,当其他线程来取的时候就可能取到空对象。
关于指令重排讲解:指令重排在单线程时是没有关系的,只有在多线程的情况下才会产生我们不想要的结果。我们都知道java代码会编译成一系列的字节码指令,然后才能在cpu中执行,当然这些指令进入cpu是有顺序的,但执行中就未必了,由于cpu可能对指令进行重排序(当然这里cpu也不是乱重排序,编译器会对不存在数据依赖的指令进行重排以提高整体程序的执行速度)。比如:这里的instance = new LazySingleton();操作,就存在1-new(堆中开辟空间)、2-invokespecial(初始化对象)、3-astore_1(将instance指向堆中的地址)这些指令,cpu可能会变成1-new(堆中开辟空间)、3-astore_1(将instance指向堆中的地址)、2-invokespecial(初始化对象),这时候相对其他的线程是不可见的,当其他线程来取的时候就可能取到空对象。
最终的版本-Double Check Lock(DCL)+volatile
饿汉模式实现单例
类加载的 初始化阶段就完成了 实例的初始化 。本质上就是借助于jvm类加载机制,保证实例的唯一性(初始化过程只会执行一次)及线程安全(JVM以同步的形式来完成类加载的整个过程)
静态内部类实现单例
1).本质上是利用类的加载机制来保证线程安全
2).只有在实际使用的时候,才会触发类的初始化,所以也是懒加载的一种形式。
初始实现版本
存在的问题:如果当通过反射创建实例的时候是会创建多个对象的,前面的一样是可以通过反射攻击的
防反射改良实现版本
枚举类型实现单例
1)天然不支持反射创建对应的实例,且有自己的反序列化机制
2)利用类加载机制保证线程安全
3)枚举底层是一个final class,一样可以写方法
2)利用类加载机制保证线程安全
3)枚举底层是一个final class,一样可以写方法
序列化方式实现单例
1)将对象序列化到文件中,然后通过流再反序列化成对象
2)类中提供的获取方式用流获取方式
3)反序列化成对象也是通过调构造函数创建的
创建型--建造者模式
对于建造者模式而已,它主要是将一个复杂对象的构建与表示分离,使得同样的构建过程可以创建不同的表示。适用于那些产品对象的内部结构比较复杂。
建造者模式将复杂产品的构建过程封装分解在不同的方法中,使得创建过程非常清晰,能够让我们更加精确的控制复杂产品对象的创建过程,同时它隔离了复杂产品对象的创建和使用,使得相同的创建过程能够创建不同的产品。但是如果某个产品的内部结构过于复杂,将会导致整个系统变得非常庞大,不利于控制,同时若几个产品之间存在较大的差异,则不适用建造者模式,毕竟这个世界上存在相同点大的两个产品并不是很多,所以它的使用范围有限
创建型--原型模式
指原型实例指定创建对象的种类,通过复制现有的实例来创建新的实例。存在浅拷贝跟深拷贝区分,没啥说的就是对象属性拷贝
结构型--享元(蝇量)模式
运用共享技术有效地支持大量细粒度的对象
在一个系统中对象会使得内存占用过多,特别是那些大量重复的对象,这就是对系统资源的极大浪费。享元模式对对象的重用提供了一种解决方案,它使用共享技术对相同或者相似对象实现重用。享元模式就是运行共享技术有效地支持大量细粒度对象的复用。系统使用少量对象,而且这些都比较相似,状态变化小,可以实现对象的多次复用。这里有一点要注意:享元模式要求能够共享的对象必须是细粒度对象。享元模式通过共享技术使得系统中的对象个数大大减少了,同时享元模式使用了内部状态和外部状态,同时外部状态相对独立,不会影响到内部状态,所以享元模式能够使得享元对象在不同的环境下被共享。同时正是分为了内部状态和外部状态,享元模式会使得系统变得更加复杂,同时也会导致读取外部状态所消耗的时间过长。
在一个系统中对象会使得内存占用过多,特别是那些大量重复的对象,这就是对系统资源的极大浪费。享元模式对对象的重用提供了一种解决方案,它使用共享技术对相同或者相似对象实现重用。享元模式就是运行共享技术有效地支持大量细粒度对象的复用。系统使用少量对象,而且这些都比较相似,状态变化小,可以实现对象的多次复用。这里有一点要注意:享元模式要求能够共享的对象必须是细粒度对象。享元模式通过共享技术使得系统中的对象个数大大减少了,同时享元模式使用了内部状态和外部状态,同时外部状态相对独立,不会影响到内部状态,所以享元模式能够使得享元对象在不同的环境下被共享。同时正是分为了内部状态和外部状态,享元模式会使得系统变得更加复杂,同时也会导致读取外部状态所消耗的时间过长。
创建对象方式独立出来,将对象缓存到起来,如果之前创建过直接返回,没有则创建且放入缓存
结构型--门面(外观)模式
对外提供一个统一的方法,来访问子系统中的一群接口。比如一个功能需要调用多个方法,此时提供一个方法,而这个方法帮我去调用了多个方法
结构型-代理模式
介绍
为其他对象提供一种代理以控制对这个对象的访问。即通过代理对象访问目标对象.这样做的好处是:可以在目标对象实现的基础上,增强额外的功能操作,即扩展目标对象的功能
代理模式有不同的形式, 主要有静态代理和动态代理, 动态代理分为JDK代理和 Cglib代理 (可以在内存动态的创建对象,而不需要实现接口)。
使用场景
按职责来划分,通常有以下使用场景: 1、远程代理。 2、虚拟代理。 3、Copy-on-Write 代理。 4、保护(Protect or Access)代理。 5、Cache代理。 6、防火墙(Firewall)代理。 7、同步化(Synchronization)代理。 8、智能引用(Smart Reference)代理。
静态代理
代理类跟目标类都实现同一个接口,代理类中需要传入目标对象,在代理类方法实现中去调用目标对象的目标方法
缺点:如果我们有多个接口的子类都需要代理,那么需要每个接口写一个代理类。动态代理就只需要创建一个,而不管你传入的子类属于哪个接口,同样它到底也会创建一个子类,只是由程序帮你动态生成而不需要我们每次手动写
缺点:如果我们有多个接口的子类都需要代理,那么需要每个接口写一个代理类。动态代理就只需要创建一个,而不管你传入的子类属于哪个接口,同样它到底也会创建一个子类,只是由程序帮你动态生成而不需要我们每次手动写
动态代理
jdk动态代理(接口代理)
核心 API 是 Proxy 类和 InvocationHandler 接口。它的原理是利用反射机制在运行时生成代理类的字节码,,根据字节码找到class对象,然后反射生成对象
public static Object newProxyInstance(ClassLoader loader, Class<?>[] interfaces,InvocationHandler h )
注意该方法是在Proxy类中是静态方法,且接收的三个参数依次为:
注意该方法是在Proxy类中是静态方法,且接收的三个参数依次为:
- ClassLoader loader,:指定当前目标对象使用类加载器,获取加载器的方法是固定的
- Class<?>[] interfaces,:目标对象实现的接口的类型,使用泛型方式确认类型
- InvocationHandler h:事件处理,执行目标对象的方法时,会触发事件处理器的方法,会把当前执行目标对象的方法作为参数传入
ProxyGenerator生成代理类的字节码文件解析
代理类是通过Proxy类的ProxyClassFactory工厂生成的,这个工厂类会去调用ProxyGenerator类的generateProxyClass()方法来生成代理类的字节码。ProxyGenerator这个类存放在sun.misc包下,我们可以通过OpenJDK源码来找到这个类,该类的generateProxyClass()静态方法的核心内容就是去调用generateClassFile()实例方法来生成Class文件,generateClassFile()方法是按照Class文件结构进行动态拼接的(返回一个二进制流然后进行类加载)
1.代理类默认继承Porxy类,因为Java中只支持单继承,所以JDK动态代理只能去实现接口。
2.代理方法都会去调用InvocationHandler的invoke()方法,因此我们需要重写InvocationHandler的invoke()方法。
3.调用invoke()方法时会传入代理实例本身,目标方法和目标方法参数。解释了invoke()方法的参数是怎样来的。
Cglib代理
Cglib代理,也叫作子类代理,它是在内存中构建一个子类对象从而实现对目标对象功能的扩展.
- JDK的动态代理有一个限制,就是使用动态代理的对象必须实现一个或多个接口,如果想代理没有实现接口的类,就可以使用Cglib实现.
- Cglib是一个强大的高性能的代码生成包,它可以在运行期扩展java类与实现java接口.它广泛的被许多AOP的框架使用,例如Spring AOP和synaop,为他们提供方法的interception(拦截)
- Cglib包的底层是通过使用一个小而块的字节码处理框架ASM来转换字节码并生成新的类.不鼓励直接使用ASM,因为它要求你必须对JVM内部结构包括class文件的格式和指令集都很熟悉.
CGLIB 通过动态生成一个需要被代理类的子类(即被代理类作为父类),该子类重写被代理类的所有不是 final 修饰的方法,并在子类中采用方法拦截的技术拦截父类所有的方法调用,进而织入横切逻辑。此外,因为 CGLIB 采用整型变量建立了方法索引,这比使用 JDK 动态代理更快(使用 Java 反射技术创建代理类的实例)
jdk与cglib区别
- JDK 动态代理只能对接口进行代理,不能对普通的类进行代理,这是因为 JDK 动态代理生成的代理类,其父类是 Proxy,且 Java 不支持类的多继承。
- CGLIB 能够代理接口和普通的类,但是被代理的类不能被 final 修饰,且接口中的方法不能使用 final 修饰。
- JDK 动态代理使用 Java 反射技术进行操作,在生成类上更高效。
- CGLIB 使用 ASM 框架直接对字节码进行修改,使用了 FastClass 的特性。在某些情况下,类的方法执行会比较高效。
jdk通过反射调用目标方法(然后在方法前后进行加强),cglib则是直接修改字节码,相当于直接把我们写的加强方法的字节码写到代理对象里面去
都会生成新的字节码文件
结构型--适配器模式
将一个类的接口转换成客户希望的另一个接口。Adapter模式使得原本由于接口不兼容而不能一起工作的那些类可以一起工作
对象适配器模式
类适配器模式
结构型--装饰器模式
在不改变原有对象的基础上,将功能附加到对象上,可以一直加强:案例中美颜已经是装饰类了,下面又继续对美颜类进行加强
结构型-桥接模式
桥接(Bridge)模式的定义如下:将抽象与实现分离,使它们可以独立变化。它是用组合关系代替继承关系来实现,从而降低了抽象和实现这两个可变维度的耦合度。
假如你有一个几何形状(Shape)类, 从它能扩展出两个子类: 圆形(Circle)和方形(Square)。你希望对这样的类层次结构进行扩展以使其包含颜色, 所以你打算创建名为红色(Red)和蓝色(Blue)的形状子类。但是,由于你已有两个子类,所以总共需要创建四个类才能覆盖所有组合,例如蓝色圆形(BlueCircle)和红色方形(RedSquare)。
在层次结构中新增形状和颜色将导致代码复杂程度指数增长。例如添加三角形状,你需要新增两个子类,也就是每种颜色一个;此后新增一种新颜色需要新增三个子类,即每种形状一个。如此以往,情况会越来越糟糕。
解决办法:
问题的根本原因是我们试图在两个独立的维度——形状与颜色——上扩展形状类。这在处理类继承时是很常见的问题。
桥接模式通过将继承改为组合的方式来解决这个问题。具体来说,就是抽取其中一个维度并使之成为独立的类层次,这样就可以在初始类中引用这个新层次的对象,从而使得一个类不必拥有所有的状态和行为。
在层次结构中新增形状和颜色将导致代码复杂程度指数增长。例如添加三角形状,你需要新增两个子类,也就是每种颜色一个;此后新增一种新颜色需要新增三个子类,即每种形状一个。如此以往,情况会越来越糟糕。
解决办法:
问题的根本原因是我们试图在两个独立的维度——形状与颜色——上扩展形状类。这在处理类继承时是很常见的问题。
桥接模式通过将继承改为组合的方式来解决这个问题。具体来说,就是抽取其中一个维度并使之成为独立的类层次,这样就可以在初始类中引用这个新层次的对象,从而使得一个类不必拥有所有的状态和行为。
原始代码
形状
颜色
我们可以直接调用Shape.draw()进行画一个圆形或者方形,但是说如果我想画一个红色的圆形呢?是不是得写一个RedCircleShape(红色的圆形)实现接口?那如果说原来越多的组合呢?那这个类结构就会很复杂会有很多个子类,这个时候我们使用桥接模式就挺好
桥接模式示例代码
定义一个抽象类,实现形状接口Shape,并且添加一个Colour的类型对象将我们需要的颜色传进来
定义一个圆形实现类实现我们的桥接类BridgeShape
当我们要一个方形的,可以再实现一个
测试代码
结构型-过滤器模式
过滤器模式(filter pattern),允许开发人员使用不同的标准来过滤一个对象,通过逻辑运算以解耦的方式把它们连接起来,属于构建型模式,又称标准模式(criteria pattern)。
标准(Criteria)
可以使用一个类随意组合具体过滤器
行为型--策略模式
定义了算法族,分别封装起来,让它们之间可以互相替换,此模式的变化独立于算法的使用者。
应用场景
1.当你有很多类似的类,但它们执行某些行为的方式不同时,请使用此策略。
2.使用该模式将类的业务逻辑与算法的实现细节隔离开来,这些算法在逻辑上下文中可能不那么重要。
3.当你的类具有大量的条件运算符,并且在同一算法的不同变体之间切换时,请使用此模式。
策略模式主要由这三个角色组成,环境角色(Context)、抽象策略角色(Strategy)和具体策略角色(ConcreteStrategy)。
Strategy: 抽象策略类:策略是一个接口,该接口定义若干个算法标识,即定义了若干个抽象方法(如下图的algorithm())
Context: 环境类 /上下文类:
Strategy: 抽象策略类:策略是一个接口,该接口定义若干个算法标识,即定义了若干个抽象方法(如下图的algorithm())
Context: 环境类 /上下文类:
- 上下文是依赖于接口的类(是面向策略设计的类,如下图Context类),即上下文包含用策略(接口)声明的变量(如下图的strategy成员变量)。
- 上下文提供一个方法(如下图Context类中的的lookAlgorithm()方法),持有一个策略类的引用,最终给客户端调用。该方法委托策略变量调用具体策略所实现的策略接口中的方法(实现接口的类重写策略(接口)中的方法,来完成具体功能)
案例场景讲解
抽象类策略
具体实现类
上下文类
行为型--观察者模式(发布订阅模式)
观察者模式(Observer Design Pattern):在对象之间定义一个一对多的依赖,当一个对象状态改变的时候,所有依赖的对象都会得到通知并自动更新。
和我们日常生活息息相关的红绿灯,灯就相当于被观察者,行人就相当于观察者,当灯发生变化时,行人会有相应的动作:红灯停,绿灯行,黄灯亮了等一等。
和我们日常生活息息相关的红绿灯,灯就相当于被观察者,行人就相当于观察者,当灯发生变化时,行人会有相应的动作:红灯停,绿灯行,黄灯亮了等一等。
①、Subject 被观察者(红绿灯):定义被观察者必须实现的职责, 它必须能够动态地增加、 取消观察者。 它一般是抽象类或者是实现类, 仅仅完成作为被观察者必须实现的职责: 管理观察者并通知观察者。
②、Observer观察者(行人):观察者接收到消息后, 即进行update(更新方法) 操作, 对接收到的信息进行处理。
③、ConcreteSubject具体的被观察者:定义被观察者自己的业务逻辑, 同时定义对哪些事件进行通知。
④、ConcreteObserver具体的观察者:每个观察在接收到消息后的处理反应是不同, 各个观察者有自己的处理逻辑。
②、Observer观察者(行人):观察者接收到消息后, 即进行update(更新方法) 操作, 对接收到的信息进行处理。
③、ConcreteSubject具体的被观察者:定义被观察者自己的业务逻辑, 同时定义对哪些事件进行通知。
④、ConcreteObserver具体的观察者:每个观察在接收到消息后的处理反应是不同, 各个观察者有自己的处理逻辑。
观察者模式通用代码
观察者模式使用三个类 Subject、Observer 和 Client。Subject 对象带有绑定观察者到 Client 对象和从 Client 对象解绑观察者的方法。我们创建 Subject 类、Observer 抽象类和扩展了抽象类 Observer 的实体类。
观察者
被观察者
测试
JDK 实现
在 JDK 的 java.util 包下,已经为我们提供了观察者模式的抽象实现,感兴趣的可以看看,内部逻辑其实和我们上面介绍的差不多。
观察者 java.util.Observer
被观察者 java.util.Observable
其实像我们很多场景,某一个事件发生,接下来就有其他事件去响应。这不就是发布订阅吗 比如用户注册,注册成功发送mq消息 消费者送积分
就像spring的事件通知
就像spring的事件通知
行为型--责任链模式
责任链模式(Chain of Responsibility Pattern)为请求创建了一个接收者对象的链。这种模式给予请求的类型,对请求的发送者和接收者进行解耦。这种类型的设计模式属于行为型模式。
场景实现
场景说明
小明所在的公司,请假需要领导进行审批,
如果请假天数半天到1天,需要主管审批,
如果请假1到3天,主管审批完之后,还需要部门经理审批。
请假3到30天的,主管和部门经历审批完之后,还需要总经理进行处理。
角色
员工请求的类:LeaveRequest
抽象的责任处理类:AbstractHandler
主管审批的处理类:DirectLeaderHandler
部门经理处理类:DeptManagerHandler
总经理处理类:GManagerHandler
实现步骤
定义一个抽象的责任处理类AbstractHandler,定义一个抽象方法 handlerRequest的作用是用来处理请求的。定义一个设置下级的处理者。
定义主管审批的处理类:DirectLeaderHandler
定义部门经理:DeptManagerHandler
定义总经理处理类:GManagerHandler
定义员工请求的类:LeaveRequest
具体的使用
设计模式的七大原则
1、开闭原则(Open Close Principle)
开闭原则的意思是:对扩展开放,对修改关闭。在程序需要进行拓展的时候,不能去修改原有的代码,实现一个热插拔的效果。简言之,是为了使程序的扩展性好,易于维护和升级。想要达到这样的效果,我们需要使用接口和抽象类,后面的具体设计中我们会提到这点。
2、里氏代换原则(Liskov Substitution Principle)
里氏代换原则是面向对象设计的基本原则之一。 里氏代换原则中说,任何基类可以出现的地方,子类一定可以出现。LSP 是继承复用的基石,只有当派生类可以替换掉基类,且软件单位的功能不受到影响时,基类才能真正被复用,而派生类也能够在基类的基础上增加新的行为。里氏代换原则是对开闭原则的补充。实现开闭原则的关键步骤就是抽象化,而基类与子类的继承关系就是抽象化的具体实现,所以里氏代换原则是对实现抽象化的具体步骤的规范。
3、依赖倒转原则(Dependence Inversion Principle)
这个原则是开闭原则的基础,具体内容:针对接口编程,依赖于抽象而不依赖于具体。
4、接口隔离原则(Interface Segregation Principle)
这个原则的意思是:客户端不应该被迫依赖它们不使用的接口,使用多个隔离的接口,比使用单个接口要好。它还有另外一个意思是:降低类之间的耦合度。由此可见,其实设计模式就是从大型软件架构出发、便于升级和维护的软件设计思想,它强调降低依赖,降低耦合。
5、迪米特法则,又称最少知道原则(Demeter Principle)
最少知道原则是指:一个实体应当尽量少地与其他实体之间发生相互作用,使得系统功能模块相对独立。
6、单一职责原则(Single Responsibility Principle,SRP)
一个类应该仅有一个引起它变化的原因。如果一个类承担过多职责,就等于把这些职责耦合在一起,当一个职责变化时,可能会影响到其他不应该改变的职责。
7、合成复用原则(Composite Reuse Principle)
尽量使用对象组合,而不是继承来达到复用的目的。组合可以提供更大的灵活性,在运行时动态地定义新的行为,而继承是在编译时定义行为。
开闭原则的意思是:对扩展开放,对修改关闭。在程序需要进行拓展的时候,不能去修改原有的代码,实现一个热插拔的效果。简言之,是为了使程序的扩展性好,易于维护和升级。想要达到这样的效果,我们需要使用接口和抽象类,后面的具体设计中我们会提到这点。
2、里氏代换原则(Liskov Substitution Principle)
里氏代换原则是面向对象设计的基本原则之一。 里氏代换原则中说,任何基类可以出现的地方,子类一定可以出现。LSP 是继承复用的基石,只有当派生类可以替换掉基类,且软件单位的功能不受到影响时,基类才能真正被复用,而派生类也能够在基类的基础上增加新的行为。里氏代换原则是对开闭原则的补充。实现开闭原则的关键步骤就是抽象化,而基类与子类的继承关系就是抽象化的具体实现,所以里氏代换原则是对实现抽象化的具体步骤的规范。
3、依赖倒转原则(Dependence Inversion Principle)
这个原则是开闭原则的基础,具体内容:针对接口编程,依赖于抽象而不依赖于具体。
4、接口隔离原则(Interface Segregation Principle)
这个原则的意思是:客户端不应该被迫依赖它们不使用的接口,使用多个隔离的接口,比使用单个接口要好。它还有另外一个意思是:降低类之间的耦合度。由此可见,其实设计模式就是从大型软件架构出发、便于升级和维护的软件设计思想,它强调降低依赖,降低耦合。
5、迪米特法则,又称最少知道原则(Demeter Principle)
最少知道原则是指:一个实体应当尽量少地与其他实体之间发生相互作用,使得系统功能模块相对独立。
6、单一职责原则(Single Responsibility Principle,SRP)
一个类应该仅有一个引起它变化的原因。如果一个类承担过多职责,就等于把这些职责耦合在一起,当一个职责变化时,可能会影响到其他不应该改变的职责。
7、合成复用原则(Composite Reuse Principle)
尽量使用对象组合,而不是继承来达到复用的目的。组合可以提供更大的灵活性,在运行时动态地定义新的行为,而继承是在编译时定义行为。
容器
docker
docker入门
docker简介
Docker是一个开源的容器引擎,它有助于更快地交付应用。 Docker可将应用程序和基础设施层隔离(最重要的就是隔离性),并且能将基础设施当作程序一样进行管理。使用 Docker可更快地打包、测试以及部署应用程序,并可以缩短从编写到部署运行代码的周期。
Docker的优点
简化程序
Docker 让开发者可以打包他们的应用以及依赖包到一个可移植的容器中,然后发布到任何流行的 Linux 机器上,便可以实现虚拟化。Docker改变了虚拟化的方式,使开发者可以直接将自己的成果放入Docker中进行管理。方便快捷已经是 Docker的最大优势,过去需要用数天乃至数周的 任务,在Docker容器的处理下,只需要数秒就能完成。
避免选择恐惧症
如果你有选择恐惧症,还是资深患者。Docker 帮你 打包你的纠结!比如 Docker 镜像;Docker 镜像中包含了运行环境和配置,所以 Docker 可以简化部署多种应用实例工作。比如 Web 应用、后台应用、数据库应用、大数据应用比如 Hadoop 集群、消息队列等等都可以打包成一个镜像部署。
节省开支
一方面,云计算时代到来,使开发者不必为了追求效果而配置高额的硬件,Docker 改变了高性能必然高价格的思维定势。Docker 与云的结合,让云空间得到更充分的利用。不仅解决了硬件管理的问题,也改变了虚拟化的方式。
Docker的架构
Docker daemon( Docker守护进程)
Docker daemon是一个运行在宿主机( DOCKER-HOST)的后台进程。可通过 Docker客户端与之通信。
Client( Docker客户端)
Docker客户端是 Docker的用户界面,它可以接受用户命令和配置标识,并与 Docker daemon通信。图中, docker build等都是 Docker的相关命令。
Images( Docker镜像)
Docker镜像是一个只读模板,它包含创建 Docker容器的说明。它和系统安装光盘有点像,使用系统安装光盘可以安装系统,同理,使用Docker镜像可以运行 Docker镜像中的程序。
Container(容器)
容器是镜像的可运行实例。镜像和容器的关系有点类似于面向对象中,类和对象的关系。可通过 Docker API或者 CLI命令来启停、移动、删除容器。
Registry
Docker Registry是一个集中存储与分发镜像的服务。构建完 Docker镜像后,就可在当前宿主机上运行。但如果想要在其他机器上运行这个镜像,就需要手动复制。此时可借助 Docker Registry来避免镜像的 手动复制。
一个 Docker Registry可包含多个 Docker仓库,每个仓库可包含多个镜像标签,每个标签对应一个 Docker镜像。这跟 Maven的仓库有点类似,如果把 Docker Registry比作 Maven中央仓库的话,那么 Docker仓库就可理解为某jar包的路径,而镜像标签则可理解为jar包的版本号。如果Docker Registry比作中央仓库,Images又相当于我们的本地maven仓库
Docker Registry可分为公有Docker Registry和私有Docker Registry。 最常⽤的Docker Registry莫过于官⽅的Docker Hub, 这也是默认的Docker Registry。 Docker Hub上存放着⼤量优秀的镜像, 我们可使⽤Docker命令下载并使⽤。
Docker Registry可分为公有Docker Registry和私有Docker Registry。 最常⽤的Docker Registry莫过于官⽅的Docker Hub, 这也是默认的Docker Registry。 Docker Hub上存放着⼤量优秀的镜像, 我们可使⽤Docker命令下载并使⽤。
Docker虚拟化原理
docker结构
传统虚拟化和容器技术结构比较
传统虚拟化技术是在硬件层面实现虚拟化,增加了系统调用链路的环节,有性能损耗;容器虚拟化技术以共享宿主机Kernel的方式实现,几乎没有性能损耗
docker利用的是宿主机的内核,而不需要Guest OS。因此,当新建一个容器时,docker不需要和虚拟机一样重新加载一个操作系统内核。避免了寻址、加载操作系统内核这些比较费时费资源的过程,当新建一个虚拟机时,虚拟机软件需要加载Guest OS,这个新建过程是分钟级别的。而docker由于直接利用宿主机的操作系统,则省略了这个过程,因此新建一个docker容器只需要几秒钟
传统虚拟机建立在Guest OS上,是存在一台真正的操作系统镜像,而容器技术是通过docker engine(引擎)去访问Host OS的东西,自然就很快
Docker是如何将机器的资源进行隔离的?
答案是联合文件系统,常见的有AUFS、Overlay、devicemapper、BTRFS和ZFS等
以Overlay2举例说明,Overlay2的架构图如下:
原理:overlayfs在linux主机上只有两层,一个目录在下层,用来保存镜像(docker),另外一个目录在上层,用来存储容器信息。在overlayfs中,底层的目录叫做lowerdir,顶层的目录称之为upperdir,对外提供统一的文件系统为merged。当需要修改一个文件时,使用COW(Copy-on-write)将文件从只读的Lower复制到可写的Upper进行修改,结果也保存在Upper层。在Docker中,底下的只读层就是image,可写层就是Container(容器)。
写时复制 (CoW) 技术详解
所有驱动都用到的技术—写时复制,Cow全称copy-on-write,表示只是在需要写时才去复制,这个是针对已有文件的修改场景。比如基于一个image启动多个Container,如果每个Container都去分配一个image一样的文件系统,那么将会占用大量的磁盘空间。而CoW技术可以让所有的容器共享image的文件系统,所有数据都从image中读取,只有当要对文件进行写操作时,才从image里把要写的文件复制到自己的文件系统进行修改。所以无论有多少个容器共享一个image,所做的写操作都是对从image中复制到自己的文件系统的副本上进行,并不会修改image的源文件,且多个容器操作同一个文件,会在每个容器的文件系统里生成一个副本,每个容器修改的都是自己的副本,互相隔离,互不影响。使用CoW可以有效的提高磁盘的利用率。所以容器占用的空间是很少的。除非写的文件多了占用的空间才会多起来
用时分配 (allocate-on-demand)
用时分配是针对原本没有这个文件的场景,只有在要新写入一个文件时才分配空间,这样可以提高存储资源的利用率。比如启动一个容器,并不会因为这个容器分配一些磁盘空间,而是当有新文件写入时,才按需分配新空间
docker中的镜像分层技术的原理是什么呢?
docker使用共享技术减少镜像存储空间,所有镜像层和容器层都保存在宿主机的文件系统/var/lib/docker/中,由存储驱动进行管理,尽管存储方式不尽相同,但在所有版本的Docker中都可以共享镜像层。在下载镜像时,Docker Daemon会检查镜像中的镜像层与宿主机文件系统中的镜像层进行对比,如果存在则不下载,只下载不存在的镜像层,这样可以非常节约存储空间
Docker Compose入门
Docker Compose 介绍
使用微服务架构的应用系统一般包含若干个微服务,每个微服务一般都会部署多个实例。如果每个微服务都要手动启停,那么效率之低、维护量之大可想而知。本文将讨论如何使用 Docker Compose来轻松、高效地管理容器。为了简单起见将 Docker Compose简称为 Compose。
Compose 是一个用于定义和运行多容器的Docker应用的工具。使用Compose,你可以在一个配置文件(yaml格式)中配置你应用的服务,然后使用一个命令,即可创建并启动配置中引用的所有服务
Docker Compose入门
Compose的使用非常简单,只需要编写一个docker-compose.yml,然后使用docker-compose 命令操作即可。docker-compose.yml描述了容器的配置,而docker-compose 命令描述了对容器的操作
Docker Compose管理容器的结构
Docker Compose将所管理的容器分为三层,分别是工程( project),服务(service)以及容器( container)。 Docker Compose运行目录下的所有文件( docker-compose.yml、 extends文件或环境变量文件等)组成一个工程(默认为 docker-compose.yml所在目录的目录名称)。一个工程可包含多个服务,每个服务中定义了容器运行的镜像、参数和依赖,一个服务可包括多个容器实例。
示例里工程名称是 docker-compose.yml 所在的目录名。该工程包含了1个服务,服务名称是 eureka,执行 docker-compose up时,启动了eureka服务的1个容器实例
同一个docker compose内部的容器之间可以用服务名相互访问,服务名就相当于hostname,可以直接 ping 服务名,得到的就是服务对应容器的ip,如果服务做了扩容,一个服务对应了多个容器,则 ping 服务名 会轮询访问服务对应的每台容器ip ,docker底层用了LVS等技术帮我们实现这个负载均衡。
Kubernetes
Kubernetes快速实战与核心原理
K8S 是什么?
K8S 是Kubernetes的全称,源于希腊语,意为“舵手”或“飞行员”,官方称其是:用于自动部署、扩展和管理“容器化(containerized)应用程序”的开源系统。翻译成大白话就是:“K8S 是负责自动化运维管理多个跨机器 Docker 程序的集群”
K8S核心特性
- 服务发现与负载均衡:无需修改你的应用程序即可使用陌生的服务发现机制。
- 存储编排:自动挂载所选存储系统,包括本地存储。
- Secret和配置管理:部署更新Secrets和应用程序的配置时不必重新构建容器镜像,且不必将软件堆栈配置中的秘密信息暴露出来。
- 批量执行:除了服务之外,Kubernetes还可以管理你的批处理和CI工作负载,在期望时替换掉失效的容器。
- 水平扩缩:使用一个简单的命令、一个UI或基于CPU使用情况自动对应用程序进行扩缩。
- 自动化上线和回滚:Kubernetes会分步骤地将针对应用或其配置的更改上线,同时监视应用程序运行状况以确保你不会同时终止所有实例。
- 自动装箱:根据资源需求和其他约束自动放置容器,同时避免影响可用性。
- 自我修复:重新启动失败的容器,在节点死亡时替换并重新调度容器,杀死不响应用户定义的健康检查的容器
K8S 核心架构原理
K8S 是属于主从设备模型(Master-Slave 架构),即有 Master 节点负责核心的调度、管理和运维,Slave 节点则执行用户的程序。但是在 K8S 中,主节点一般被称为Master Node 或者 Head Node,而从节点则被称为Worker Node 或者 Node。
注意:Master Node 和 Worker Node 是分别安装了 K8S 的 Master 和 Woker 组件的实体服务器,每个 Node 都对应了一台实体服务器(虽然 Master Node 可以和其中一个 Worker Node 安装在同一台服务器,但是建议 Master Node 单独部署),所有 Master Node 和 Worker Node 组成了 K8S 集群,同一个集群可能存在多个 Master Node 和 Worker Node。
Master Node组件
- API Server。K8S 的请求入口服务。API Server 负责接收 K8S 所有请求(来自 UI 界面或者 CLI 命令行工具),然后,API Server 根据用户的具体请求,去通知其他组件干活。
- Scheduler。K8S 所有 Worker Node 的调度器。当用户要部署服务时,Scheduler 会选择最合适的 Worker Node(服务器)来部署。
- Controller Manager。K8S 所有 Worker Node 的监控器。Controller Manager 有很多具体的 Controller, Node Controller、Service Controller、Volume Controller 等。Controller 负责监控和调整在 Worker Node 上部署的服务的状态,比如用户要求 A 服务部署 2 个副本,那么当其中一个服务挂了的时候,Controller 会马上调整,让 Scheduler 再选择一个 Worker Node 重新部署服务。
- etcd。K8S 的存储服务。etcd 存储了 K8S 的关键配置和用户配置,K8S 中仅 API Server 才具备读写权限,其他组件必须通过 API Server 的接口才能读写数据
Worker Node组件
- Kubelet。Worker Node 的监视器,以及与 Master Node 的通讯器。Kubelet 是 Master Node 安插在 Worker Node 上的“眼线”,它会定期向 Master Node 汇报自己 Node 上运行的服务的状态,并接受来自 Master Node 的指示采取调整措施。负责控制所有容器的启动停止,保证节点工作正常。
- Kube-Proxy。K8S 的网络代理。Kube-Proxy 负责 Node 在 K8S 的网络通讯、以及对外部网络流量的负载均衡。
- Container Runtime。Worker Node 的运行环境。即安装了容器化所需的软件环境确保容器化程序能够跑起来,比如 Docker Engine运行环境
在大概理解了上面几个组件的意思后,我们来看下上面用K8S部署Nginx的过程中,K8S内部各组件是如何协同工作的:
- 我们在master节点执行一条命令要master部署一个nginx应用(kubectl create deployment nginx --image=nginx)
- 这条命令首先发到master节点的网关api server,这是matser的唯一入口
- api server将命令请求交给controller mannager进行控制
- controller mannager 进行应用部署解析
- controller mannager 会生成一次部署信息,并通过api server将信息存入etcd存储中
- scheduler调度器通过api server从etcd存储中,拿到要部署的应用,开始调度看哪个节点有资源适合部署
- scheduler把计算出来的调度信息通过api server再放到etcd中
- 每一个node节点的监控组件kubelet,随时和master保持联系(给api-server发送请求不断获取最新数据),拿到master节点存储在etcd中的部署信息
- 假设node2的kubelet拿到部署信息,显示他自己节点要部署某某应用
- kubelet就自己run一个应用在当前机器上,并随时给master汇报当前应用的状态信息
- node和master也是通过master的api-server组件联系的
- 每一个机器上的kube-proxy能知道集群的所有网络,只要node访问别人或者别人访问node,node上的kube-proxy网络代理自动计算进行流量转发
K8S 核心概念
Deployment
Deployment负责创建和更新应用程序的实例。创建Deployment后,Kubernetes Master 将应用程序实例调度到集群中的各个节点上。如果托管实例的节点关闭或被删除,Deployment控制器会将该实例替换为群集中另一个节点上的实例。这提供了一种自我修复机制来解决机器故障维护问题。
Pod
Pod相当于逻辑主机的概念,负责托管应用实例。包括一个或多个应用程序容器(如 Docker),以及这些容器的一些共享资源(共享存储、网络、运行信息等)。
Service
Service是一个抽象层,它定义了一组Pod的逻辑集,并为这些Pod支持外部流量暴露、负载均衡和服务发现。
尽管每个Pod 都有一个唯一的IP地址,但是如果没有Service,这些IP不会暴露在群集外部。Service允许您的应用程序接收流量。Service也可以用在ServiceSpec标记type的方式暴露,type类型如下:
- ClusterIP(默认):在集群的内部IP上公开Service。这种类型使得Service只能从集群内访问。
- NodePort:使用NAT在集群中每个选定Node的相同端口上公开Service。使用 <NodeIP>:<NodePort> 从集群外部访问Service。是ClusterIP的超集。
- LoadBalancer:在当前云中创建一个外部负载均衡器(如果支持的话),并为Service分配一个固定的外部IP。是NodePort的超集。
- ExternalName:通过返回带有该名称的CNAME记录,使用任意名称(由spec中的externalName指定)公开Service。不使用代理。
k8s中的资源
k8s中所有的内容都抽象为资源, 资源实例化之后,叫做对象,上面说的那些核心概念都是k8s中的资源。
k8s中有哪些资源
k8s中有哪些资源
- 工作负载型资源(workload): Pod,ReplicaSet,Deployment,StatefulSet,DaemonSet等等
- 服务发现及负载均衡型资源(ServiceDiscovery LoadBalance): Service,Ingress等等
- 配置与存储型资源: Volume(存储卷),CSI(容器存储接口,可以扩展各种各样的第三方存储卷)
- 特殊类型的存储卷:ConfigMap(当配置中心来使用的资源类型),Secret(保存敏感数据),DownwardAPI(把外部环境中的信息输出给容器)
- 集群级资源:Namespace,Node,Role,ClusterRole,RoleBinding(角色绑定),ClusterRoleBinding(集群角色绑定)
- 元数据型资源:HPA(Pod水平扩展),PodTemplate(Pod模板,用于让控制器创建Pod时使用的模板),LimitRange(用来定义硬件资源限制的)
资源清单
之前我们直接用命令创建deployment,pod,service这些资源,其实在k8s中,我们一般都会使用yaml格式的文件来创建符合我们预期期望的资源,这样的yaml文件我们一般称为资源清单
K8S 高级特性
K8S中还有一些高级特性有必要学习下,比如弹性扩缩应用(见上文)、滚动更新(见上文)、配置管理、存储卷、网关路由等。
ReplicaSet
ReplicaSet确保任何时间都有指定数量的Pod副本在运行。通常用来保证给定数量的、完全相同的Pod的可用性。建议使用Deployment来管理ReplicaSet,而不是直接使用ReplicaSet。
ConfigMap
ConfigMap是一种API对象,用来将非机密性的数据保存到键值对中。使用时,Pod可以将其用作环境变量、命令行参数或者存储卷中的配置文件。使用ConfigMap可以将你的配置数据和应用程序代码分开。
ConfigMap允许你将配置文件与镜像文件分离,以使容器化的应用程序具有可移植性
Volume
Volume指的是存储卷,包含可被Pod中容器访问的数据目录。容器中的文件在磁盘上是临时存放的,当容器崩溃时文件会丢失,同时无法在多个Pod中共享文件,通过使用存储卷可以解决这两个问题。
常用的存储卷有如下几种:
- configMap:configMap卷提供了向Pod注入配置数据的方法。ConfigMap对象中存储的数据可以被configMap类型的卷引用,然后被Pod中运行的容器化应用使用。
- emptyDir:emptyDir卷可用于存储缓存数据。当Pod分派到某个Node上时,emptyDir卷会被创建,并且Pod在该节点上运行期间,卷一直存在。当Pod被从节点上删除时emptyDir卷中的数据也会被永久删除。
- hostPath:hostPath卷能将主机节点文件系统上的文件或目录挂载到你的Pod中。在Minikube中的主机指的是Minikube所在虚拟机。
- local:local卷所代表的是某个被挂载的本地存储设备,例如磁盘、分区或者目录。local卷只能用作静态创建的持久卷,尚不支持动态配置。
- nfs:nfs卷能将NFS(网络文件系统)挂载到你的Pod中。
- persistentVolumeClaim:persistentVolumeClaim卷用来将持久卷(PersistentVolume)挂载到Pod中。持久卷(PV)是集群中的一块存储,可以由管理员事先供应,或者使用存储类(Storage Class)来动态供应,持久卷是集群资源类似于节点。
通过存储卷,我们可以把外部数据挂载到容器中去,供容器中的应用访问,这样就算容器崩溃了,数据依然可以存在。
总结
Ingress
通过K8S的Ingress资源可以实现类似Nginx的基于域名访问,从而实现Pod的负载均衡访问。
总结
ervice 是 K8S 服务的核心,屏蔽了服务细节,统一对外暴露服务接口,真正做到了“微服务”。举个例子,我们的一个服务 A,部署了 3 个备份,也就是 3 个 Pod;对于用户来说,只需要关注一个 Service 的入口就可以,而不需要操心究竟应该请求哪一个 Pod。优势非常明显:一方面外部用户不需要感知因为 Pod 上服务的意外崩溃、K8S 重新拉起 Pod 而造成的 IP 变更,外部用户也不需要感知因升级、变更服务带来的 Pod 替换而造成的 IP 变化,另一方面,Service 还可以做流量负载均衡。
但是,Service 主要负责 K8S 集群内部的网络拓扑。集群外部需要用 Ingress 。
Ingress 是整个 K8S 集群的接入层,复杂集群内外通讯。
Ingress 和 Service 的网络拓扑关系图如下:
K8S真的放弃Docker了吗?
Docker作为非常流行的容器技术,之前经常有文章说它被K8S弃用了,取而代之的是另一种容器技术containerd!其实containerd只是从Docker中分离出来的底层容器运行时,使用起来和Docker并没有啥区别,从Docker转型containerd非常简单,基本没有什么门槛。只要把之前Docker命令中的docker改为crictl基本就可以了,都是同一个公司出品的东西,用法都一样。所以不管K8S到底弃用不弃用Docker,对我们开发者使用来说,基本没啥影响!
K8S CRI
K8S发布CRI(Container Runtime Interface),统一了容器运行时接口,凡是支持CRI的容器运行时,皆可作为K8S的底层容器运行时。
K8S为什么要放弃使用Docker作为容器运行时,而使用containerd呢?
如果你使用Docker作为K8S容器运行时的话,kubelet需要先要通过dockershim去调用Docker,再通过Docker去调用containerd。
如果你使用containerd作为K8S容器运行时的话,由于containerd内置了CRI插件,kubelet可以直接调用containerd。
使用containerd不仅性能提高了(调用链变短了),而且资源占用也会变小(Docker不是一个纯粹的容器运行时,具有大量其他功能)。
当然,未来Docker有可能自己直接实现K8S的CRI接口来兼容K8S的底层使用。
计划
1
rocketmq、kafka、rabitmq、netty、redis、zk、shardingsphere
2
jvm、java集合、并发、mysql、xxl-job、elk
3
cloud、Spring、其他、设计模式、容器
4
算法
5
项目
0 条评论
下一页