Java基础篇
SaaS也就是常说的软件及服务,是一种软件交付模型,SaaS不向用户交付最终的软件产品,软件作为用户使用的服务而存在。它就相当于软件中的租借而非购买。
也就是说,我只需要能连接上互联网,并且给saas平台交租金,我就能用saas平台给我提供的服务。
实现方式是所有租户共享同一个应用,但应用后端会连接多个数据库系统,一个租户单独使用一个数据库系统。这种方案的用户数据隔离级别最高,安全性最好,租户间的数据能够实现物理隔离。但成本较高。
就是所有租户共享同一个应用,应用后端只连接一个数据库系统,所有租户共享这个数据库系统,每个租户在数据库系统中拥有一个独立的表空间。
这种方案是多租户方案中最简单的设计方式,即在每张表中都添加一个用于区分租户的字段(如租户id或租户代码)来标识每条数据属于哪个租户,其作用很像外键。当进行查询的时候每条语句都要添加该字段作为过滤条件,其特点是所有租户的数据全都存放在同一个表中,数据的隔离性是最低的,完全是通过字段来区分的。
在JDK1.8之前
HashMap是数组 + 链表结合在一起使用也就是 散列表。HashMap key的hashCode经过扰动函数处理过后得到hash值,然后通过(n -1) & hash判断当前元素存放的位置(n指的是数组长度),如果当前位置存在元素的话,就判断元素与要存入的元素的hash值以及key是否相等,如果相同的话直接覆盖,不相同就通过拉链法解决冲突。
所谓的扰函数指的就是HashMap的hash方法,使用扰动函数可以减少hash碰撞。
所谓的拉链法就是 将链表和数组相结合,也就是说创建一个链表数组,数组中的每一格就是一个链表,如果遇到hash冲突,则将冲突的值加到链表中即可。
JDK1.8之后
相比较于JDK1.8之前,JDK1.8之后的HashMap在解决冲突时有了较大的变化,当链表的长度大于阈值(默认为8)之后就会将链表转换为红黑树,如果当前数组的长度小于64,那么会选择先进行数组扩容,而不是转换为红黑树,链表转换为红黑树以减少搜索的时间。
HashMap的长度为什么是2的幂次方?
为了能够让HashMap存取高效,尽量减少碰撞,也就是要尽量的把数据分配均匀,所以使用了2的幂次方。
HashMap多线程操作导致死循环问题
主要原因就是在于并发下的Rehash会造成元素空间形成一个循环链表,不过JDK1.8版本之后解决了这个问题,但是还是不建议在多线程下使用HashMap,因为多线程下的HashMap还是会存在其他问题比如数据丢失,并发下推荐使用CurrentHashMap,这个是线程安全的。
(1)、HashMap1.8扩容时会首先检测数组元素的个数,因为loadFactor的默认值是0.75,它含有的桶的数量默认是16,它的阈值是 16 * loadFactor,当它哈希桶占用的容量大于12的时候,就会触发扩容。就把数组的大小扩展为 2 * 16 = 32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知了HashMap的个数,那么最好就提前预设元素的个数能够有效的提高性能,
(2)、如果当某个桶中的链表长度达到8的进行链表扭转为红黑树的时候,会检查总的桶数是否小于64,如果总桶数小于64也会进行扩容。
(3)、当new完HashMap之后,第一次往HashMap进行put操作时,首先会进行扩容
第一范式:每一列都必须是原子性的
第二范式:首先必须满足第一范式,并且所有非主属性都完全依赖于主码
第三范式:满足第二范式,所有非主属性对任何候选关键字都不存在依赖传递。也就是说每个属性和主键都有直接关系而不是间接关系。
数组简单易用,在实现上使用的是连续的内存空间,这样就可以借助CPU的缓存机制,预读数组中的数据,所以访问效率效率很高,而链表在内存中并不是连续存储的,无法利用CPU的缓存机制,没有办法有效预读。
如果你的代码对内存的使用非常苛刻,那么数组就更适合你,因为链表中的每个节点都需要消耗额外的内存空间去存储一份指向下一个节点的指针,所以内存消耗会翻倍,而且对链表频繁的插入和删除操作,还会导致频繁的内存申请和释放,容易造成内存碎片,Java就会导致频繁的GC。
维护一个有序单链表,越靠近链表尾部的节点就是越早之前访问的。当有一个新的数据被访问的时候,我们从链表头开始顺序遍历链表。
如果此数据没有在缓存链表中,又可以分为两种情况
但是这种思路时间复杂度会比较高。
如何优化?
引入Hash,来记录每个数据的位置,将换粗访问的时间复杂度直接降到O(1),
使用双向链表
可以通过 O(1) 的时间淘汰掉双向链表的尾部,每次新增和访问数据,都可以通过 O(1)的效率把新的节点增加到对头,或者把已经存在的节点移动到队头。
HashMap的put操作做了什么?
HashMap的是由数组和链表构成的,JDK7之后加入了红黑树处理哈希冲突。put操作的步骤是这样的:
根据key值计算出哈希值作为数组下标。如果数组的这个位置是空的,把k放进去,put操作就完成了。
如果数组位置不为空,这个元素必然是个链表。遍历链表逐一比对value,如果value在链表中不存在,就把新建节点,将value放进去,put操作完成。
如果链表中value存在,则替换原节点的value,put操作完成。
如果链表节点数已经达到8个,首先判断当前hashMap的长度,如果不足64,只进行resize,扩容table,如果达到64就将冲突的链表为红黑树。
get方法调用
1、当调用get方法时会调用hash函数,这个hash函数会将key的hashCode值返回,返回的hashcode与entry数组长度-1进行逻辑与运算得到一个index值,用这个index值来确定数据存储在entry数组当中的位置。
2、通过循环来遍历索引位置对应的链表,初始值为数据存储在entry数组当中的位置,循环条件为entry对象不为null,改变循环条件为entry对象的下一个节点。
3、如果hash函数得到的hash值与entry对象中key的hash值相等并且entry对象当中的key值与get方法传进来的key值equals相同则返回entry对象的value值,否则返回null。
比如说user_name是个索引,当执行该SQL:select * from user_info where `user_name` = 'xiaoming'; InnoDB 就会建立 user_name 索引 B+树,节点里存的是 user_name 这个 KEY,叶子节点存储的数据的是主键 KEY。注意,叶子存储的是主键 KEY!拿到主键 KEY 后,InnoDB 才会去主键索引树里根据刚在 user_name 索引树找到的主键 KEY 查找到对应的数据。
主键索引树的叶子结点是直接存储数据的。
因为 InnoDB 需要节省存储空间。一个表里可能有很多个索引,InnoDB 都会给每个加了索引的字段生成索引树,如果每个字段的索引树都存储了具体数据,那么这个表的索引数据文件就变得非常巨大(数据极度冗余了)。从节约磁盘空间的角度来说,真的没有必要每个字段索引树都存具体数据,通过这种看似“多此一举”的步骤,在牺牲较少查询的性能下节省了巨大的磁盘空间,这是非常有值得的。
JDK7
数组 + 链表
比如HashMap的put,map.put(“张三”,20)
存放到数组里面就是一组 [key: 张三,value 20,hash 43545,next:null]
HashMap实现了MapEntry这个接口,HashMap的扩容机制和HashSet完全一样
HashMap的扩容机制
请你说说HashMap的put过程以及扩容机制
在Java8中,当一个链表的元素超过了treeify_threshold (8) -1,并且table的大小大于64时就会进行树化,转换为红黑树。
ConcurrentHashMap对整个桶数组进行了分割分段(Segment),然后在每一个分段上都用lock锁进行保护,相对于HashTable的synchronized关键字锁的粒度更精细了一些,并发性能更好,而HashMap没有锁机制,不是线程安全的。
HashMap的键值对允许有null,但是ConCurrentHashMap都不允许
HashTable和HashMap采用相同的存储机制,二者的实现基本一致,不同的是:
(1)HashMap是非线程安全的,HashTable是线程安全的,内部的方法基本都经过synchronized修饰。
(2)因为同步、哈希性能等原因,性能肯定是HashMap更佳,因此HashTable已被淘汰。
(3) HashMap允许有null值的存在,而在HashTable中put进的键值只要有一个null,直接抛出NullPointerException。
(4)HashMap默认初始化数组的大小为16,HashTable为11。前者扩容时乘2,使用位运算取得哈希,效率高于取模。而后者为乘2加1,都是素数和奇数,这样取模哈希结果更均匀。
1.在JDK1.7中,当并发执行扩容操作时会造成环形链(死循环)和数据丢失的情况。
2.在JDK1.8中,在并发执行put操作时会发生数据覆盖的情况。
JDK1.7线程不安全主要体现在transfer函数中。
Transfer这段代码是HashMap的扩容操作,重新定位每个桶的下标,并采用头插法将元素迁移到新数组中。头插法会将链表的顺序翻转,这也是形成死循环的关键点。
JDK1.8线程不安全主要体现在putVal方法中
判断是否存在hash碰撞的那段代码
假设两个线程A、B都在进行put操作,并且hash函数计算出的插入下标是相同的,当线程A执行完第六行代码后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。
它们都可以用于多线程的环境,但当HashTable数据增大到一定程度的时候,由于很多地方都使用了synchronized修饰,性能会急剧下降,因为迭代时需要被锁定很长的时间。
HashTablde任何操作都会把整个实例对象锁住,是阻塞的。好处是:总能获取到最实时的更新,比如线程A调用putAll写入大量的数据,期间线程B调用get,那么线程B就会阻塞,直到线程A完成putAll(),因此线程B肯定能获取到线程A的完成数据。坏处是所有的调用都需要排队,效率较低。
ConcurrentHashMap设计为非阻塞的,在更新时局部锁住某部分数据,但不会吧把整个表都锁住,同步读取操作则是完全非阻塞的,好处是保证合理的同步前提下,效率很高,坏处是:严格来说,读取操作不能保证反映最近的更新,例如线程A调用putAll写入大量的数据,期间线程B调用get读取数据,则只能get到目前为止已经顺利插入的部分数据。
JDK8的版本,与JDK6的版本有很大差异。实现线程安全的思想也已经完全变了,它摒弃了它同时期的HashMap版本的思想,底层依然由数组+链表+红黑树的方式思想,但是为了做到并发,又增加了很多复制类,例如TreeBin、Traverser等对象内部类。CAS算法实现无锁化的修改至操作,他可以大大降低锁代理的性能消耗。这个算法的基本思想就是不断地去比较当前内存中的变量值与你指定的一个变量值是否相等,如果相等,则接受你指定的修改的值,否则拒绝你的操作。因为当前线程中的值已经不是最新的值,你的修改很可能会覆盖掉其他线程修改的结果。
HotSpot VM是目前市面上高性能虚拟机的代表作之一。它采用解释器与编译器并存的架构。
程序计数器:当前线程所执行的字节码的行号指示器,用于存储指向下一条指令的地址,由执行引擎读取下一条指令。分支、循环、跳转、异常处理、线程恢复等功能都需要依赖它来完成,生命周期与线程的生命周期保持一致。线程私有。唯一一个不会出现内存溢出的区域
Java栈:存放基本数据类型、对象的引用、方法出口等、线程私有
本地方法栈:和虚拟机相似、只不过它服务于Native方法,线程私有
Java堆:内存最大块,所有对象实例、数组都存放在Java堆,GC回收的地方,线程共享。
方法区(HotSpot独有):存放已经被加载的类信息、常量、静态变量、即时编译后的代码,回收目标主要是常量池的回收和类型的卸载,线程共享
如果我们一个进程中有五个线程、则就会有五个本地方法栈、虚拟机栈、程序计数器,一起共享一个方法区和一个堆空间(Heap)
①虚拟机的启动
虚拟机的启动是通过引导类加载器创建一个初始类来完成的,这个类是由虚拟机的具体实现指定的。
②虚拟机的执行
一个运行中的Java虚拟机有着一个清晰的任务:执行Java程序。
程序开始执行时他才运行,程序结束时它就停止。
执行一个所谓的Java程序的时候,真真正正在执行的是一个叫做Java虚拟机的进程。
③虚拟机的退出
程序正常执行结束。
程序在执行过程中遇到了异常或错误而异常终止。
由于操作系统错误而导致Java虚拟机终止。
某线程调用Runtime类或者System类的exit方法,或Runtime类的halt方法。
一个Java文件从编码完成到最终执行,一般就经历以下两个过程
编译:将Java文件通过Javac命令编译成字节码文件,也就是我们看到的.class文件
运行:将字节码文件交给Jvm执行
加载(Loading)
通过一个类的全限定名获取此类的二进制字节流,将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构,在内存中生成一个代表这个类的Class对象,作为方法区这个类的各种数据的访问入口。
所有能够被Java虚拟机识别的有效的字节码文件的开头都是 “CA FE BA BE”
链接(Linking)
初始化(Initialization)
初始化阶段就是执行类构造器方法的过程,此方法不需要定义,是Javac编译器自动收集类中的所有变量的赋值动作和静态代码块中的语句合并而来的,只对static修饰的变量或者语句进行初始化。如果初始化一个类时,其父类尚未初始化,则先初始化父类。如果同时包含多个静态变量或者静态语句,则按照顺序执行。
自定义加载器:
隔离加载类、修改类加载的方式、扩展加载源、防止源码泄漏
如何实现:
Java虚拟机对class文件采用的是 按需加载 的方式,也就是说当需要用到该类时才会去加载它的class文件到内存并生成class对象。而且加载某个类的class文件时,Java虚拟机采用的是 双亲委派机制,即把请求交由父类处理,它是一种任务委派模式。
①如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行。
②如果父类的加载器还存在其父类的加载器,则进一步向上委托,依次递归,请求最终达到顶层的启动类加载器。
③如果父类加载器就可以完成类加载,则成功返回,倘若父类加载器无法完成此类加载任务,子类加载器才会尝试自己去加载,这就是双亲委派机制。
例如我们自己在java.lang包下面新建一个String类,在自定义的String中创建一个main方法,然后执行main方法,会提示 “在类java.lang.String中找不到main方法,请将main方法定义为......”,这就说明压根就没去加载我们自定义的String类,而是交给父类加载器去加载了核心API里面的String类。(这也就是沙箱安全机制)
比如自定义String类,但是在加载自定义Stirng类时会率先使用引导类加载器加载,而引导类加载器在加载过程中会先加载JDK自带的文件。这样可以保证核心API的安全。这就是沙箱安全机制。
线程是一个程序里的运行单元。Jvm允许一个应用有多个线程并行的执行,在HotspotJVM里,每个线程与操作系统的本地线程直接映射。当一个Java线程准备好执行以后,此时一个操作系统的本地线程也会同时创建。Java线程销毁,本地线程也会随着回收。操作系统负责所有线程的安排调度到任何一个可用的CPU上,一旦本地线程初始化成功,它就会调用Java线程中的run方法。
Hotspot Jvm中的线程有哪些?
因为CPU在不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行。
比如有A、B两个线程,执行A到一半的时候,停止并且去执行B线程了,那么这个时候就需要记录下A线程的字节码指令地址,等到B线程执行完毕之后在回来执行A线程。
所谓的多线程并发在一个特定的时间段内只会执行其中某一个线程的方法。由于CPU会不停的做任务切换,这样必然会导终端或者是恢复。如何保证分毫不差呢,为了能够准确的记录各个线程正在执行的当前字节码指令地址,最好的办法自然就是为每个线程都分配一个PC寄存器,这样一来各个线程之间便可以独立计算,而不会出现相互干扰。
并行:多个线程在多个不同的CPU上同时执行,真正意义上的同时执行
并发:多个线程任务在一个CPU上迅速的切换运行,由于速度非常快,给人的感觉就是一起执行,但其实只是逻辑意义上的同时执行。
局部变量表、操作数栈、动态链接、方法出口信息
局部变量表包括:编译期可知的各种数据类型、引用类型
追问1:什么是线程安全的?什么是线程不安全的?
答1:如果只有一个线程操作此数据,必须是线程安全的。
如果有多个线程操作此数据,则此数据是共享的,如果不考虑同步机制的话,就会存在线程安全问题。那就是线程不安全的。
追问2:方法中的局部变量是线程安全的吗?
答2:如果说局部变量在方法内创建、并且在方法内消亡,就是线程安全的
如果返回到外部则线程就是不安全的。
比如递归陷入死循环(没有设置好递归头和递归尾)
可以通过-Xss设置栈的大小
追问1:调整栈大小,就能保证不出现溢出吗?
答1:不能,比如本身就是一个死循环,那依旧会发生栈溢出。
追问2:垃圾回收是否会涉及到虚拟机栈
答2:不会涉及GC
追问3:分配的栈内存越大越好吗?
答3:不是,内存空间是有限的,不能盲目分配。
并不是所有的Jvm都支持本地方法,因为Java虚拟机规范并没有明确的规定本地方法栈的使用语言、具体实现方式、数据结构等。
在HotspotJVM中,直接将本地方法栈和虚拟机栈合二为一。
本地方法被执行的时候,在本地方法栈中也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息等。方法执行完毕后相应的栈帧也会出栈并释放内存空间。
追问:堆空间都是线程共享的吗?
答:不都是,比如缓冲区就是线程私有的(TLAB),每个线程有一份,这样并发性能会好很多。
当我们把程序运行起来的时候,Jvm实例就通过一个叫做bootstrap的引导类加载器将Jvm运行起来了。这就是大致的过程。
Java7及之前堆内存逻辑上分为三部分:新生区、养老区、永久区
Java8及之后堆内存逻辑上分为三部分:新生区、养老区、元空间
分Java堆用于存储Java对象实例,那么堆的大小在Jvm启动时就已经设定好了,可以通过选项”-Xmx” 和 “-Xms”来进行设置
“-Xms”: 表示的是堆区的起始内存 -X是Jvm的运行参数 ms是启始内存
“-Xmx”:表示的是堆区的最大内存
一旦堆区的内存代销超过了 “-Xmx”的所指定的最大内存时,将会抛出OutOfMemoryError异常。
通常将 “-Xms”和”-Xmx”两个参数配置相同的值,其目的就是为了能够在Java垃圾回收机制清理完堆区后不需要重新分割计算堆区的大小,从而提高性能。
默认情况下,初始内存大小:物理电脑内存大小 / 64
最大内存大小:物理电脑内存大小 / 4
按照收集区域分为:部分收集器、整堆收集器
部分收集器:不是完整收集Java堆的收集器,又分为
整堆收集(Full GC): 收集整个Java堆和方法区的垃圾收集器
新生代GC触发条件:新生代空间不足就会触发MinorGC,这里年轻代指的是Eden代满。Survivor不满不会引发GC。MinorGC会引发STW,暂停其他用户线程,等垃圾回收结束,用户线程才恢复继续。
老年代GC触发条件:老年代空间不足时会尝试触发MinorGC,如果空间还是不足,则触发MajorGC。如果MajorGC后内存还是不足,则报错OOM。MajorGC速度比MinorGC慢10倍。
FullGC触发条件:(1)、调用System.gc(),系统会执行FullGC,但不是立即执行。(2)、老年代空间不足、(3)、方法区空间不足、(4)、通过MinorGC进入老年代平均大小大于老年代可用内存。
标记清除法、标记整理法、复制算法、分代收集算法
标记清除法
利用可达性去遍历内存,把存活对象和垃圾对象进行标记。标记结束后在对垃圾对象进行回收,这种效率低。会产生大量的内存碎片。
标记整理法
根据老年代的特点提出的一种标记算法,标记过程和”标记清除法”一致,但后续步骤不是对可回收对象进行清理,而是让所有存活对象都朝一端移动,然后直接清理掉边界以外的内存。
复制清除算法
用于新生代垃圾回收。将内存分为大小相同的两块,每次使用其中的一块。当这一块内存使用完毕后,就将还存活的对象复制到另一块去,再把使用的空间一次清理掉。
分代收集算法
根据各个年代的特点采用最适当的收集算法
新生代:复制清除算法
老年代:使用标记清除算法或者标记整理算法
比如我们使用IO流时,未关闭连接会导致留下的开放连接消耗内存,持续占有内存,如果不处理,就会降低性能,甚至OOM
(5)、内存中加载的数据量对于庞大的,比如从数据库一次取出很多数据存放在内存中
内存泄露和内存溢出是不一样的,内存泄露所导致的越来越多的内存得不到回收时候,就会导致内存溢出。
内存溢出的解决方案
真实案例
MinorGC频繁,MajorGC频繁
情况:MinorGC每分钟100次 ,MajorGC每4分钟一次,单次MinOR GC耗时25ms,单次Major GC耗时的200ms,接口响应时间为50ms
解决方案
首先优化MinORGC频繁的问题,通常情况下,由于新生代空间较小,Eden区很快就被填满,就会导致频繁的MinorGC ,因此可以增大新生代空间来降低的MinorGC的频率,例如在相同的内存分配率的前提下,新生代中的Eden区增加一倍,MinorGC的次数就会减少一半。
Java公有API中有两种可以主动调用GC的办法
(1)、System.gc(); 只是告诉JVM尽快GC一次(建议),但不会立即执行GC
(2)、Runtime.getRuntime().gc();
进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的
在Java中我们启动main函数时其实就是启动了一个JVM的进程,而main函数所在的线程就是这个进程中的一个线程,也称作主线程。
线程是一个比进程等更小的执行单位,一个进程在其执行的过程中可以产生多个线程,线程也被称作轻量级的进程。
执行Java代码时,程序计数器主要用来记录的是下一条指令的地址,在多线程运行情况下,程序计数器必须记住当前线程的位置,从而当线程被来回切换时能够知道该线程上次运行到哪里了。所以必须是线程私有的。这样切换后才能恢复到正确的执行位置。
并发:同一时间段多个任务都在执行
并行:单位时间内多个任务同时执行
并发编程的目的是为了能提高程序的执行效率提高程序的运行速度,但是并发编程并不总是能提高程序运行速度的,而是并发编程可能遇到很多问题,比如内存泄漏、上下文切换、死锁等。
线程安去就是多线程访问时,采用了加锁机制,当一个线程访问该类的某个数据时,进行保护,其他线程不能进行访问直到该线程读取完,其他线程才可使用。不会出现数据不一致或者数据污染。 线程不安全就是不提供数据访问保护,有可能出现多个线程先后更改数据造成所得到的数据是脏数据。(共享数据哦)
等待阻塞:运行中的线程执行wait()方法,使得本线程进入等待阻塞状态
同步阻塞:线程在获取synchronized同步锁失败,就会进入同步阻塞状态
其他阻塞:通过调用线程的sleep()或join或者发出IO请求时
多线程中一般线程的个数都大于CPU核心的个数,而一个CPU核心在任一时刻只能被一个线程使用。为了让这些线程都得到有效执行,CPU采取的策略是为了每个线程分配时间片轮转的形式,当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程属于一次上下文切换。
死锁:多个线程同时被阻塞,它们中的一个或者是全部都在等某个资源被释放。由于线程被无限期的阻塞,因此程序不可能正常终止。
四大条件
线程池是一种基于池化思想管理和使用线程的机制,它是将多个线程预先存储在一个池子内,当有任务出现时可以避免重新创建和销毁线程所带来的的性能开销,只需要从池子内取出相应的线程执行对应的任务即可。如果不使用线程池,则可能导致系统创建大量同类线程而导致消耗完内存或者 “过度切换”的问题。
优点:
创建方式
具体创建方式
ThreadPoolExecutor包含的参数有哪些?
为什么不建议使用Executors创建线程?
(1) FixedThreadPool 和 SingleThreadPool:允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。
(2)CachedThreadPool:允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM。
共同点:两者都可以暂停线程的执行
notify方法可以随机唤醒等待队列中的等待同一共享资源的“一个”线程,并使得该线程退出等待队列,进入可运行状态,也就是notify方法仅通知一个线程。
notifyAll方法可以使所有正在等待队列中等待同一个共享资源的“全部”线程从等待状态退出,进入可运行状态,此时优先级最高的线程最新执行,但也有可能是随机执行。
Synchronized关键字解决的是多个线程之间访问同一个资源的同步性,synchronized关键字可以保证被他修饰的方法或者代码块在任意时刻只能被同一个线程访问。
在Java早期版本中,synchronized属于重量级锁,效率低下,Java6后面在JVM层面对synchronized关键字进行了优化,所以现在的synchronized性能不错。
尽量不要使用 synchronized(String a)因为Jvm中字符串常量池具有缓存功能
构造方法不能使用synchronized关键字修饰,因为本身就是线程安全的,不存在同步构造方法一说。
同步代码块是使用monitorenter和monitorexit指令实现的,同步方法依靠的是方法修饰上的ACC_SYNCHRONIZED实现。
当多个线程访问某个方法时,不管你通过怎样调用方式或者说如何交替的执行,我们在主线程中都不需要去任何的同步,程序依旧能够按照我们的预期执行,那么这就是线程安全。
线程安全:
非线程安全的:
Volatile 关键字是线程同步的轻量级实现,所以volatile性能肯定比synchronized关键字要好,
volatile只能作用于变量而synchronized关键字可以修饰方法以及代码块。
Volatile能保证数据的可见性,但是不能保证数据的原子性,synchronized两者都可以保证
Volatile 主要用于解决变量在多个线程之间的可见性,而synchronized关键字解决的是多个线程之间访问资源的同步性。
作用:使用volatile修饰的成员变量,就是告知程序任何对该变量的访问均需要从共享内存中获取,而对它的改变必须同步刷新回共享内存,它能保证所有线程对变量访问的可见性。
原理:强制把修改的数据写回内存。
我们创建的变量是可以被任何一个线程修改并访问的,如果想实现每一个线程都有自己的专属变量该如何解决?
可以使用ThreadLocal类,这个类主要解决的就是让每个线程绑定自己的值,ThreadLocal也就是存放数据的盒子,盒子中可以存放每个线程中私有的数据。
自旋锁:当一个线程在获取锁的时候,如果锁已经被其他线程获取,那么它就会循环等待,不断的判断锁是否能成功获取到,直到获取到锁才的退出循环。
如果某个线程持有锁的时间更长,就会导致其他等待获取锁的时间过长,消耗CPU,使用不当会造成CPU使用率极高。
自旋锁的不是公平的,即无法满足等待时间最长的线程优先获取锁,就会导致 “线程饥饿”问题。
Java中的自旋锁_孙悟空2015的博客-CSDN博客_java自旋锁
自己可以获取自己的内部锁,当线程请求自己持有的锁对象时,如果锁是重入锁,线程请求成功。
比如有一个线程A获取到了对象锁,此时这个对象锁还没有被释放,当其再次想要获取这个对象锁时是可以获取的,如何不可锁重入的话,就会造成死锁。
可重入锁主要是为了避免死锁的。Java的synchronized和ReentrantLock都是可重入锁。
AQS全称是 AbstractQueuedSynchronized,这个类在java.ugtil.concurrent.locks包下面。
AQS是一个用来构建锁和同步器的框架,使用AQS能简单高效的构造出应用广泛的大量同步器,核心原理就是:如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且被共享资源设置为锁定状态,如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的资源放队列中。
在并发程序中,串行操作是会降低可伸缩性,并且上下文切换也会减低性能。在锁上发生竞争时将通水导致这两种问题,使用独占锁时保护受限资源的时候,基本上是采用串行方式—-每次只能有一个线程能访问它。所以对于可伸缩性来说最大的威胁就是独占锁。
我们一般有三种方式降低锁的竞争程度:
1、减少锁的持有时间
2、降低锁的请求频率
3、使用带有协调机制的独占锁,这些机制允许更高的并发性。
在某些情况下我们可以将锁分解技术进一步扩展为一组独立对象上的锁进行分解,这成为分段锁。其实说的简单一点就是:容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术,首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。
比如:在ConcurrentHashMap中使用了一个包含16个锁的数组,每个锁保护所有散列桶的1/16,其中第N个散列桶由第(N mod 16)个锁来保护。假设使用合理的散列算法使关键字能够均匀的分布,那么这大约能使对锁的请求减少到越来的1/16。也正是这项技术使得ConcurrentHashMap支持多达16个并发的写入线程。
场景描述
秒杀系统中,模拟1000个用户在一秒内并发请求下单,如果使用synchronized关键字防止超卖问题(也就是悲观锁的方式),同时加了注解@Transcation,会发生什么问题呢?该把synchronized放在哪里呢?
扣减库存正常,但是订单数量远远大于商品售卖的数量。
原因是synchronized加在了service层,同时加了@Transcation注解,也就是说在执行该方法开始时,事务启动,执行完毕后,事务关闭。但是synchronized没有起作用,其实根本原因是因为事务的范围比锁的范围大,也就是说在加锁的那部分代码执行完毕后,锁释放掉了,但是事务还没结束,此时另一个线程进来了,事务没结束的话另一个线程进来时,数据库的状态和第一个线程刚进来时是一样的,即由于MySQL存储引擎默认隔离级别是可重复读,线程2事务开始时,线程1还没提交完成,导致读取的数据还没更新,第二个线程也做了插入动作,导致了脏数据。所以在查询库存、扣减库存、新增订单一系列操作中才会发生订单数远远大于商品售卖数的情况。
解决方案
Start()方法用来启动新创建的线程,而start()内部其实调用了run()方法,这和直接调用run()方法的效果不太一样,当你调用run()方法时,只会在原来的线程中调用,没有新的线程启动,而调用start()方法会启动一个新的线程。
Java中提供了丰富的API,但没有为停止线程提供API,当run或者是call方法执行完成后,线程会自动结束,如果要手动结束,可以使用volatile布尔变量来推出run方法的循环或者是取消任务来中断线程。或者是抛出异常,最好的方式就是抛出异常,抛出异常的方式可以让线程停止的事件得以传播。
如果异常没有被捕获则该线程会停止执行。
高并发常见的面试题_Cynthia_wpp的博客-CSDN博客_高并发面试题
通过synchronized关键字来实现互斥,它有一些缺点。比如你不能扩展锁以外的方法或者块边界,尝试获取锁时不能中途取消等。java5 通过Lock接口提供了更复杂的控制来解决这些问题。ReentrantLock类实现了Lock,它拥有与synchronized相同的并发性和内存语义且它还具有可扩展性。
在多线程中有多重方法让线程按照特定的顺序执行,可以使用线程类的join方法在一个线程中启动另一个线程,另外一个线程完成后该线程继续执行,为确保顺序执行,你应该先启动最后一个,(T3调用T2,T2调用T1),这样T1就会先完成而T3最后完成。
找不到。B+树索引找到的只是数据行对应的页,然后数据库通过把页读入到内存,再在内存中进行查找,最后找到对应的数据行。使用的算法是 “二分查找算法”。
二叉查找树:左子树的键值总是小于根的键值,右子树的键值总是大于根的键值
平衡查找树:平衡查找树由二叉查找树演变而来,特点是左子树的键值总是小于根的键值,右子树的键值总是大于根的键值、任何节点的两个子树的高度最大差为1。 平衡二叉树的插入、删除、更新操作都需要进行左旋右旋操作,因此维护一棵平衡二叉树是有一定的开销的,不过平衡二叉树多用于内存结构的对象中,因此维护的开销比较小。
B+树是由B树和索引顺序访问方法演化而来的,是为了磁盘或其他直接存取辅助设备设计的一种平衡二叉树,在B+树中,所有的记录节点都是按照键值的大小顺序存放在同一层的叶子结点上的。由各个叶子结点指针进行连接。
在进行插入删除等操作时,为了保持平衡,B+树必须进行大量的拆分页(split)的操作,而B+树主要是用于磁盘的,split操作意味着进行磁盘操作,所以为了在可能的情况下尽可能的减少split操作,B+树加入了旋转的功能。且旋转发生在Leaf Page已经满,但是其左右兄弟节点没有满的情况。
聚集索引
辅助索引
两者的区别是叶子结点存放的是否是一整行的信息。
聚集索引就是按照每张表的主键构造的一棵B+树,同时叶子结点中存放的是数据记录,也将聚集索引的叶子结点称为数据页,聚集索引的这个特性决定了索引组织表中的数据也是索引的一部分。每个数据页都是使用双向链表进行连接的。
多数情况下查询优化器都比较偏向于聚集索引,因此聚集索引能够在B+树索引的叶子结点上找到数据。
数据页上存放的都是完整的每行的记录,而非数据页上的索引页中存放的仅仅是键值和指向数据页的偏移量,而不是一个完整的行记录。
注意:聚集索引是逻辑上的连续,并非物理上的连续。
服务容器负责启动,加载,运行服务提供者。
服务提供者在启动时,向注册中心注册自己提供的服务。
服务消费者在启动时,向注册中心订阅自己所需的服务。
注册中心返回服务提供者地址列表给消费者,如果有变更,注册中心将基于长连接推送变更数据给消费者。
服务消费者,从提供者地址列表中,基于软负载均衡算法,选一台提供者进行调用,如果调用失败,再选另一台调用。服务消费者和提供者,在内存中累计调用次数和调用时间,定时每分钟发送一次统计数据到监控中心。
分布式:是指将不同的业务分布在不同的地方,
集群:是指将几台服务器集中在一起,实现同一业务。
分布式中的每一个节点,都可以做集群,而集群并不一定就是分布式的。集群有组织性,一台服务器垮了,其它的服务器可以顶上来,而分布式的每一个节点,都完成不同的业务,一个节点垮了,哪这个业务就不可访问了。
是一系列分布式框架的集合,基于SpringBoot进行开发的,将不同公司的不同组件进行集成,以SpringBoot风格进行集成开发,开发者不需要关注底层的实现,而是开箱即用,需要哪个组件就用SpringBoot来整合。
是微服务开发提供的一套一站式的分布式解决方案,包含了分布式应用服务的必需组件。使开发者通过SpringCloud编程模型轻松的解决微服务架构下的各类技术问题。
工程结构:SpringBoot -> SpringCloud -> SpringCloudAlibaba,需要版本兼容
服务注册 + 服务发现 = 服务治理
服务注册和服务发现:Nacos
Ribbon不是SpringCloudAlibaba的组件,而是Netflix提供的。默认使用轮询算法。
负载均衡算法:轮询算法、随机算法、基于权重算法等
雪崩效应:指的是多个服务之间相互调用,其中一个服务的不可用导致整个系统瘫痪
解决方案:
降级:系统将不需要的功能接口停用,主要是应对自身的
熔断:系统将停止调用其他服务不可用的那些接口
限流:限制并发访问数或者一个时间窗口内允许处理的请求数量来保护系统,一旦达到限制的数量就采取对应的拒绝策略。本质上就是损失一部分用户的可用性,为大部分用户提供稳定可靠的服务。
需要注意关闭context整合,Sentinel默认会将Controller方法做context整合,导致链路模式的流控失效,需要修改application.yml
通俗解释:如果RT = 1s,最小请求数 = 5,就表示如果连续5次请求的响应时间都超过1S,那么就是慢调用,会降级并且抛出异常。
服务(Service)
服务指的是一个或者一组软件功能
服务注册中心(Service Registy)
服务注册中心是服务、实例、元数据的数据,服务实例在启动时注册到服务注册表,并在关闭时注销,服务和路由器的客户端查询服务注册表以查找服务的可用实例,服务注册中心可能会调用的服务实例的健康检查API来验证它是否能够处理请求。
服务元数据(Service Metadata)
指的是服务端点、服务标签、服务版本号、服务实例权重、路由规则等
SpringBoot Admin Server 是通过Spring Cloud DiscoveryClient来发现应用程序的,且使用了Spring Cloud Discovery之后不再需要使用Spring Boot Admin Client,只需要向管理服务器添加一个实现,其他一切由AutoConfiguration来完成。
根据DiscoveryClient接口可知,它里面有三个方法,我们的NacosDiscoveryClient 实现了 DiscoveryClient 接口,然后通过 getServices()方法获取所有的服务列表。大概就是这样的。
追问:如果我的工程配置了context-path,应该如何让Admin发现呢?
将context-path追加到Admin服务的URL中即可。(yml里配置)
(1)、静态路由配置:使用yml或者properties,端点是spring.cloud.gateway
缺点: 每次改动都需要把网关服务重新配置
SpringCloudGateway是基于过滤器实现的,有pre 和 post两种方式,分别是处理前置逻辑和后置逻辑。
客户端的请求经过pre类型的filter,然后将请求转发到具体的业务,收到业务服务的响应之后,再经过post类型的filter处理,最后返回响应到客户端。
Filter两大类:全局过滤器和局部过滤器。
过滤器有优先级区分,Order越大,级别越低,越晚执行
全局过滤器:RouteToRequestUrlFilter 所有的请求都会执行
局部过滤器:PrefixPathGatewayFilterFactory(添加前缀)、StripPrefixGatewayFilterFactory(去掉前缀) 只有配置的请求才执行。
SpringCloudSleuth: 它会自动的为当前应用构建起各通信通道的跟踪机制
例如通过RabbitMQ、Kafka传递的请求,通过Zull、Gateway传递的请求、通过Resttemplate传递的请求等。
SpringCloudSleuth实现原理
(1)、为了实现请求跟踪:当请求发送到分布式系统的入口端点时,只需要服务跟踪框架为该请求创建一个唯一的跟踪标识TracedID
(2)、为了统计各处理单元的时间延迟,当请求到达各个服务组件时,或者是处理逻辑到达某个状态时,也通过唯一标识来标记她的开始,具体过程以及结束,SpanID
Zipkin
解决微服务中的延迟问题,实现数据的收集、存储、查找和展现
四大核心组件
Feign是一种声明式、模板化的HTTP客户端。使用Feign,可以做到声明式调用。
尽管Feign目前已经不再迭代,处于维护状态,但是Feign仍然是目前使用最广泛的远程调用框架之一。
在SpringCloud Alibaba的生态体系内,有另一个应用广泛的远程服务调用框架Dubbo,在后面我们会接触到。
Feign是在RestTemplate 和 Ribbon的基础上进一步封装,使用RestTemplate实现Http调用,使用Ribbon实现负载均衡。
主程序入口添加了@EnableFeignClients注解开启对FeignClient扫描加载处理。根据Feign Client的开发规范,定义接口并加@FeignClientd注解。
当程序启动时,会进行包扫描,扫描所有@FeignClients的注解的类,并且将这些信息注入Spring IOC容器中,当定义的的Feign接口中的方法被调用时,通过JDK的代理方式,来生成具体的RequestTemplate.
当生成代理时,Feign会为每个接口方法创建一个RequestTemplate。当生成代理时,Feign会为每个接口方法创建一个RequestTemplate对象,该对象封装了HTTP请求需要的全部信息,如请求参数名,请求方法等信息都是在这个过程中确定的。
然后RequestTemplate生成Request,然后把Request交给Client去处理,这里指的是Client可以是JDK原生的URLConnection,Apache的HttpClient,也可以是OKhttp,最后Client被封装到LoadBalanceClient类,这个类结合Ribbon负载均衡发起服务之间的调用。
事务:是一系列对系统中数据进行访问和更新的操作所组成的一个程序执行逻辑单元
分布式事务:分布式事务问题也叫做分布式数据一致性问题,简单来说就是如何在分布式场景中保证多个节点数据的一致性。分布式事务产生的核心原因在于存储资源的分布性,比如多个数据库,MySQL和Redis两种不同存储设备的数据一致性。在实际应用中我们应该尽可能的从设计层面去避免分布式事务的问题。引入某种额外的机制来协调多个事务要么全部提交、要么全部回滚,以此来保证数据的完整性。
2PC也叫做两阶段提交,第一阶段是事务的准备阶段,第二阶段是事务的提交或者回滚阶段。
2PC过程
两种角色:事务协调者、事务参与者
第一阶段:事务协调者向事务参与者下达 “处理本地事务”的通知,事务参与者收到通知以后开始处理本地事务,本地事务处理完毕之后回复事务协调者 “本地事务处理完毕”。当事务协调者收到处理完毕的通知后开始进入第二阶段。
注意:所谓的本地事务就是每个服务该做的事情,在这个阶段所有数据都处于未提交状态。
第二阶段:事务协调者会向事务参与者下达 “开始提交”的命令,事务参与者收到通知以后开始提交事务完成数据的最终写入,提交完毕后回复事务协调者 “提交完成”,事务协调者收到 “提交成功”的通知以后,就意味着一次分布式事务完成。
注意:假设在阶段一有任何一个服务因为某种原因向事务协调者上报 “事务处理失败”,就意味着整体业务处理出现问题,阶段二的操作就会改为回滚处理,将所有未提交的数据撤回,使数据还原以保证完整性。
2PC的缺点:
那么同步阻塞的问题怎么解决呢?其实只要在服务这一侧增加超时机制,过一段时间被阻塞的事务就会自动提交,释放锁定的资源。尽管这样做会导致数据的不一致,但是也比线程积压导致服务崩溃要好一些,处于此目的,三阶段的提交(3PC)应运而生。
3PC过程
第一阶段:询问阶段。事务协调者向事务参与者询问是否可以完成事务执行,参与者只需要回答 “是”或者”否”,不需要做真正事务处理,这个阶段会有超时终止机制。
第二阶段:准备阶段。事务协调者会根据事务参与者的反馈结果决定是否被继续执行,如果在询问阶段所有参与者都回答可以执行。则事务协调者会向事务参与者下达 “执行事务”的命令,事务参与者收到命令后开始执行本地事务,执行完毕后向事务协调者回复 “事务执行完成”,事务协调者收到所有”事务执行完成”回复后,开始进入第三阶段。
第三阶段:提交阶段。事务协调者收到“事务执行完成”的反馈之后,开始下发”提交事务”的命令,事务参与者收到命令之后,开始执行commit提交事务完成数据的最终写入,写入完成后通知事务协调者 “提交完成”。反之如果有任何一方参与者返回失败的通知,则事务协调者就会发起终止命令来回滚事务。事务协调者收到所有事务参与者的通知后,一次分布式事务提交就完成了。
如果协调者服务通信中断导致无法提交,在服务端超时之后也会自动执行提交操作来保证资源的释放。加入了超时机制来保证资源不被长时间占用。
2PC和3PC的区别
超时机制带来的问题
超时机制带来的问题的解决方案有哪些?
总结:无论是2PC还是3PC得分布式事务方案都只是一种宏观设计,如果要落地最终还是需要依托具体的软件产品,比较有代表性的有ByteTCC,TXLCN,EasyTransaction,AlibabaSeata。
CAP理论又叫做布鲁尔理论,指的是在分布式环境中不可能同时满足一致性(C)、可用性(A)和分区容错性(P),最多能够同时满足两个。
C: 一致性,数据在多个节点中必须保持一致,也就是说写操作后的读操作读取到的最新数据的状态要保持一致。当数据分布在多个节点上,从任一节点读取到的数据都是最新状态。
如何实现一致性?
(1)、写入数据后要将数据同步到从数据库。
(2)、写入数据后,在向从数据库同步数据期间数据要被锁定,待同步完成后再释放锁,以免在新数据写入成功后,向数据库中查询到的是旧数据。
分布式一致性的特点
A:可用性,指的是任何事务操作都可以得到响应结果,且不会出现响应超时或者错误。
如何实现可用性?
分布式可用性的特点
P: 分区容错性。在分布式系统中遇到任何网络分区故障,系统仍然能够正常对外提供服务。
如何实现分区容错性?
分布式分区容错性的特点
CAP理论证明,在分布式系统中,要么满足CP,要么满足AP,无法满足CAP或者CA。在分布式系统中必须满足分区容错性。
AP:放弃强一致性,实现最终的一致性,很多互联网公司的选择。
CP:放弃高可用性,实现强一致性和分区容错性,2PC和3PC都采用的这种方案,可能导致的问题就是用户完成一个操作需要很长的时间,体验极差。
BASE理论是由于CAP中一致性和可用性不可兼得而衍生出来的一种新思想,BASE理论的核心思想是通过牺牲数据的强一致性来获得高可用性。
BASE理论特点
BASE理论并没有要求数据的强一致,而是允许数据在一段时间内是不一致的,但是数据最终会在某个时间点一致。在互联网产品中,大部分采用BASE理论来实现数据的一致,因为产品的可用性对于用户来说最重要。
Seata是一款开源的分布式事务解决方案,它提供了TCC、AT、Saga、XA事务模式。AT模式是目前Seata最主推的一种分布式解决方案。
TC: 事务协调器,独立运行的中间件,需要独立部署运行,维护全局事务的运行状态,接收TM指令发起全局事务提交和回滚,负责和RM通信协调各分支事务的的提交或者回滚。
TM:事务管理器,需要嵌入应用程序工作,负责开启一个全局事务,并最终向TC发起全局事务的提交或者回滚指令。
RM: 也就是事务参与者,每个数据库实例。
举例子,比如现在有用户服务和积分服务,分别对应两个数据库,需求是和用户注册成功就加10个积分,在分布式环境下如何保证数据的一致性?
传统2PC和Seata的2PC的区别是什么?
(1)、每个RM都使用DataSourceProxy连接数据库,其目的是使用ConnectionProxy,使用数据源和数据代理的目的就是在第一阶段将undo_log和业务数据放在一个本地事务提交,这样就保证只要有业务数据就一定会有undo_log,而undo_log需要在第二阶段回滚时使用。
(2)、第一阶段的undo_log中存放了数据修改前和修改后的值,为事务回滚做好了准备,所以第一阶段完成就已经将分支事务提交,也就释放了锁资源。
(3)、TM开启全局事务,将全局事务id放在事务上下文中,通过feign或者resttemplate调用将XID传入下游分支事务,每个分支事务将自己的BranchID与XID关联。
(4)、第二阶段的全局事务提交,TC会通知各个分支参与提交分支事务,在第一阶段就已经提交了分支事务,在这里参与者只需要删除undo_log即可,并且可以异步执行。
(5)、第二阶段全局事务回滚,TC会通知各个分支参与者回滚事务,通过XID和BranchID找到相应的回滚日志,生成反向SQL并执行,以完成分支事务回滚到之前的状态,如果回滚失败则会重试回滚操作。
在数据库本地事务隔离级别为“读已提交”或以上的基础上,Seata的默认全局隔离级别为 “读未提交”,有可能会产生脏读。
如果必须使用全局的读已提交,目前Seata的方式是通过SELECT FOR UPDATE语句代理,因为SELECT FOR UPDATE 属于当前读,读取的是最新的记录,读取时保证其他并发事务不修改当前记录,会对读取的记录加锁。出于性能的考虑,Seata目前的方案并没有针对所有SELECT语句都进行代理,仅针对FOR UPDATE的SELECT语句。
TCC是Try、Confirm、Cancel三个词语的缩写,TCC要求每个分支事务实现三个操作,预处理Try、确认Confirm、撤销Cancel。
(1)、Try:业务检查、资源预留。这个阶段仅仅是一个初步操作,需要和后续的Confirm配合才能完成一个真正的业务逻辑。
(2)、Confirm:业务确认,Try阶段所有分支事务执行完成后才会执行Confirm,采用TCC则认为Confirm阶段是一定不会出错的。即只要Try成功,Confirm就一定成功,若真的出错,则需要引入重试机制或者人工处理。
(3)、Cancel:撤销(回滚操作),在业务执行错误需要回滚的状态下执行分支事务的业务取消,预留资源释放,通常情况下采用TCC则认为Cancel阶段也是一定成功的。若真的出错,则需要引入重试机制或者人工处理。
如果所有分支事务的Try阶段都成功,则Confirm一定成功。如果Confirm/Cancel操作失败,则TM会进行重试。
如果分支事务的某一个Try成功,另一个Try失败,则在Confirm阶段执行Cancel撤销成功的那个分支事务,也就是回滚。
注意:TM事务管理器可以实现为独立的服务,也可以让全局事务发起方充当TM的角色,实现为独立的服务是为了成为公用组件,是为了考虑系统结构和软件复用。
TM在发起全局事务时生成全局事务记录,全局事务ID贯穿整个分布式事务的链条,用来记录事务上下文,追踪和记录状态。由于Confirm和Cancel失败都需要进行重试,所以必须实现幂等性,幂等性指的是同一个操作无论请求都少次,其结果都是相同的。
可靠消息最终一致性指的是事务的发起方执行完本地事务后,向事务的参与者发送一条消息, 事务的参与者收到消息后执行本地事务,此方案强调的是只要消息发送给事务参与者最终事务就要达到一致性。利用的是消息中间件。
注意:只要消息发出了,事务的参与者就一定能接收消息并成功处理事务,实现最终一致性。
可靠消息最终一致性的问题
方案1(存在问题):假设先发送消息再进行数据库操作:无法保证消息发送和本地事务执行的原子性,因为可能消息发送成功,但是本地事务执行失败,消息没办法回退。
方案2(存在问题):先进行数据库操作再发送消息:貌似没问题,如果MQ发送失败,则会抛出异常,抛出异常则回滚事务。单是如果因为超时异常,数据库回滚,但是消息已经发送出去了。
本地消息表 + 定时任务扫描发送:通过本地事务保证数据业务操作和消息的一致性,然后通过定时任务将消息发送到消息中间件,待确认消息发送给消费方后删除消息。消息会被记录在本地消息表中,可以是redis。
例如有一个需求:现有“用户服务”和“积分服务”,当用户注册成功之后,赠送积分。
这里就涉及两个不同数据库的操作,存在分布式事务问题,那么如何保证本地事务和消息发送的原子性呢?
——>执行完新增用户的操作之后,将 “增加积分消息”日志进行记录,存储到redis当中,然后启动独立的线程,通过定时任务来查询并发送消息到中间件,等待消费方的确认,消费方确认接收到消息之后,停止消息的发送,否则根据定时任务周期不断重试发送消息。
那么如何确认消费者已经接收到消息了呢?看下面
可以使用MQ的ack,即消息确认机制,消费者监听MQ,如果消费者接收到消息并且业务处理完成后向MQ发送ack,即确认消息,此时说明消费者正常消费消息完成,MQ将不再向消费者推送消息,否则事务发起方会不断的通过定时任务发送消息。
由于消息会重复投递,积分服务的 “增加积分”功能需要实现幂等性。
RocketMQ4.3 后实现了完整的事务消息,实际上其实是对消息表的一个封装,将本地消息表移动到了MQ内部,解决了生产端消息发送和本地事务执行的原子性问题。
具体流程如下所示:
注意:如果MQ发送方告诉MQ服务本地事务commit成功,则MQ服务就会将消息的状态改为”可消费”。如果MQ发送方告诉MQ服务rollback成功,则MQ服务就会将消息删除不进行投递。
重点在于:充值系统要把充值结果通知到账户系统,如果说因为网络等因素通知不到账户系统,则提供一个消息校对机制,账户系统可以主动的去查询充值结果。
最大努力通知的目标:发起通知方通过一定的机制尽自己最大的努力将业务处理结果通知到接收方。
具体包括
可靠消息一致性消息的可靠性关键因素是:发起方
最大努力通知消息的可靠性关键因素:接收方,因为接收方可以接收发送方的消息,也可以主动的查询消息。
(2)、两者的业务应用场景不同
可靠消息一致性关注的是交易过程的事务一致性,异步方式完成交易(交易过程)
最大努力通知关注的是交易后的通知事务,即提交结果可靠的通知出去(交易后)
可靠消息要解决的是消息从发出到接收的一致性,即消息发出并且被接受到。
最大努力通知无法保证消息从发出到接收的一致性,只提供消息接收的可靠性机制。可靠机制是最大努力的将消息发送出去,当消息无法被接收方接收时,接收方可以主动的查询消息(业务处理结果)
最大努力通知可以采用MQ的ACK机制就实现最大努力通知。
方案1:
MQ按照间隔1min、5min、10min的方式逐步扩大同志间隔,比如使用的是RocketMQ,则在broker中可以进行配置。
这种方案只适合内部应用的通知。
方案2:
也是利用MQ的ACK机制,但是与方案1不同的是增加一个“通知程序”向接收方发送通知
比如有时候咱们调用的是第三方的Api,不可能让第三方API去监听我们的MQ。
通知程序调用接收通知方接口成功就表示通知成功,即消费MQ消息成功,MQ将不再向通知程序投递通知消息。
方案1和方案2的不同点
以互联网金融项目的实际业务场景举例。
P2P金融项目也叫作P2P信贷,P2P的意思就是个人对个人,P2P金融指的是个人与个人之间的小额借贷交易,借款者可以自行的发布借款信息,包括金额、利息、还款方式和时间,实现自助式借款。投资者可以根据借款人发布的信息、自行决定出借的金额,实现自助式借贷。采用“银行存管模式”来规避P2P平台挪用借投人资金的风险,通过银行开发的银行存管系统管理投资者的资金,每位P2P平台用户在银行的存管系统内都会有一个独立的账号。平台来管理交易,做到资金和交易分开,让P2P平台不能接触到资金,在一定程度上就避免了资金被挪用的风险。
有统一账号、统一认证、用户中心、标的检索、交易中心、还款服务、支付服务、内容管理服务等8个服务构成,本人主要涉及的是用户中心、交易中心、还款服务、统一账号等服务的设计和开发,里面遇到了大量分布式事务的问题,根据我所掌握的分布式事务解决方案结合实际的业务解决了这些问题。
业务流程:
粗略流程:由于本平台采用的是用户、账号分离设计(好处是当用户的业务信息发生变更时,不会影响认证、授权等机制),因此需要保证用户信息和账号信息的一致性。也就是当用户注册成功的同时需要创建这个用户对应的账号。
具体流程:用户通过浏览器注册用户,用户中心生成用户信息,(用户名作为与账号统一关联的关联项),创建登录账号,统一账号服务需要创建账号信息,比如用户名、密码。返回给用户中心创建成功,用户中心在返回给用户注册成功。
遇到的问题:
由于是两个不同的服务,一个是用户中心服务,一个是统一账号服务,所以就存在分布式事务的问题。
业务背景:
根据要求,P2P业务必须让银行存管资金,用户的资金在银行存管系统的账户中,而不在P2P平台中,因此用户需要在银行存管系统中开户。
业务流程:
用户向用户中心提交开户资料,用户中心生成开户请求并重定向到银行存管系统开户页面。用户设置存管密码并确认开户后,银行存管立即返回 “请求已受理”。在某一时刻,银行存管系统处理完该开户请求后,调用回调地址通知处理结果,若通知失败,则按照一定的策略重试通知。同时,银行存管系统应提供开户结果查询的接口,供用户中心校对结果。
业务背景
在借款人募集够所有的资金以后,P2P运营管理员审批该标的,触发放款,并开启还款流程。
业务流程
管理员对某标的审核通过之后,交易中心修改标的状态为 “还款中”,同时要通知还款服务生成还款计划。
针对问题(1)
采用Seata的AT模式解决 账号注册中“用户中心服务”和“统一账号服务”的分布式事务问题
追问:为什么采用Seata的AT模式?
针对注册业务,如果用户和账号信息不一致,则会导致严重的问题,因此一致性要求比较高,即当用户中心服务和统一账号服务任一方出现问题都需要回滚事务。根据这个业务规则,选用Seata的AT模式来实现分布式事务,Seata的AT模式也就是2PC,依赖于数据库的,所以具有回滚功能,主要流程如下。
追问:为什么不采用可靠消息一致性方案或者是最大努力通知方案?
可靠消息一致性实现的是最终一致性,也就是可靠消息一致性要求只要消息发出,事务参与者收到消息就要将事务执行成功,不存在回滚的要求,所以不适用。
最大努力通知方案实现的也是消息的最终一致性,即使某一方事务执行失败也不会回滚事务,所以不适用。
针对问题(2)
采用MQ的ACK机制就实现最大努力通知解决此分布式事务问题。(跨系统的方案)
追问:为什么采用最大努力通知解决方案?
P2P平台的用户中心与银行存管系统之间属于跨系统交互,银行存管系统属于外部系统,用户中心无法干涉银行存管系统,所以用户中心只能在收到银行存管系统的业务处理结果通知后积极处理,开户后的使用情况完全由用户中心来控制。基于上面的业务规则,只能使用最大努力通知方案的跨系统方案来解决此分布式事务问题。主要流程如下
追问:为什么不采用Seata的AT模式、TCC或者是可靠消息一致性方案?
采用Seata的AT模式:需要侵入银行存管的数据库,由于它是外部系统,不适用。
采用TCC:侵入性更强
采用MQ可靠消息一致性:首先银行系统不可能让P2P平台去监听它的MQ,其次P2P平台的MQ也不可能让银行系统监听,所以不合适。
问题(3)
采用基于MQ的ACK机制的可靠消息最终一致性方案来解决此分布式事务问题。
追问:为什么采用可靠消息最终一致性方案呢?
此业务对一致性的要求不是很高,但是对快速响应的要求比较高,也就是时间线不能太长。因为还款服务中涉及到还款计划的计算。所以综合下来采用基于MQ的ACK机制的可靠消息最终一致性方案最为合适。
追问:为什么不采用Seata的AT模式的解决方案或者是TCC解决方案呢?
采用Seata的AT模式:Seata的AT模式会锁住资源,导致线程积压,时间长
采用TCC:也会锁住资源,时间长
SOA架构的服务之间是通过相互依赖最终提供一系列的功能,微服务是SOA架构的升华,微服务的一个重点是“业务需要彻底的组件化和服务化”,原有的单个业务系统会拆分为多个可以独立开发、设计、运行的小应用,而这些小应用之间通过服务完成交互和继承。微服务不再强调传统SOA架构里面比较重的ESB企业服务总线,同时SOA的思想进入到单个业务系统内部实现真正的组件化。(SOA架构的ESB和微服务的网关类似)
微服务带来的收益
微服务设计的原则
DDD是一种软件架构设计方法,它并不定义软件开发过程(Devops)
DDD利用面向对象的特性,以业务为核心驱动,而不是传统的数据库驱动开发
领域:领域是对功能需求的划分;大的领域下面还有许多下的子领域
传统的软件开发总是以设计数据表开始的,而DDD前期考虑的是业务,而不是数据表
工程入口及用户鉴权微服务
网关是微服务架构的唯一入口
(1)、鉴权微服务:登录、注册
(2)、网关微服务:路由配置、限流配置、过滤器
注意:网关微服务和鉴权微服务是电商工程的门面。
电商功能微服务
账户、商品、订单、物流(都在网关的背后)
解析方法:Jwt使用算法解析用户信息,Session需要额外的数据映射实现匹配
管理方法:Jwt是存储在客户端的,只有过期时间的限制,而Session是存储在服务端的,可控性更强。
跨平台:Jwt只是一段字符串,可以任意传播,Session需要有统一的解析平台
时效性:Jwt一旦生成,独立存在,很难做特殊控制,Session时效性完全由服务端说了算
Redis特点
Redis原理
Redis采用IO多路复用技术,实现多个连接共用一个线程,保证高并发下系统的吞吐量
所谓IO多路复用技术即多个网络连接对应一个线程,采用IO多路复用技术可以让单线程高效的处理连接请求。
Jedis默认是直接操作Redis的,当在并发量非常大的时候,那么Jedis操作Redis的连接数很有可能就会异常,因此为了提高操作效率,引入连接池。
Jedis池化技术在创建时初始化一些连接资源到连接池中,使用Jedis连接资源时不需要创建,而是从连接池中获取一个资源进行redis操作,使用完毕后也不需要销毁,而是将该资源归还给连接池。供其他请求使用。
缓存雪崩指的是同一时间缓存大面积的失效,然后后面所有的请求都落到了数据库上,造成数据库短时间内承受大量的请求而崩溃。
解决方案:
缓存穿透是指的缓存和数据库中都没有数据,导致所有的请求都落在数据库上,造成短时间内大量请求落在数据库而崩掉。(来自黑客攻击)
解决方案:
缓存击穿指的是缓存中没有但是数据库中有的数据,这时由于并发用户特别多,同时缓存没有读取到数据,又同时去数据库读取数据,引起数据库压力瞬间增大,造成压力过大。
缓存雪崩和缓存击穿的区别: 缓存击穿是并发访问某一数据,缓存雪崩是所有不同数据失效。
解决方案:
(1)、设置热点数据永不过期(不推荐)
(2)、加互斥锁 setNx
主从复制模式
哨兵模式(主从模式升级版)
Cluster模式
主从复制模式
指的是将redis分为主从节点,比如可以从主节点读写数据,从节点只读数据,主数据库写入的数据会实时自动同步给从数据库。
具体工作机制
(1)、slave启动后,向master发送SYNC命令,master接收到SYNC命令后通过bgsave保存快照(即上文所介绍的RDB持久化),并使用缓冲区记录保存快照这段时间内执行的写命令
(2)、master将保存的快照文件发送给slave,并继续记录执行的写命令
(3)、slave接收到快照文件后,加载快照文件,载入数据
(4)、master快照发送完后开始向slave发送缓冲区的写命令,slave接收命令并执行,完成复制初始化
(5)、此后master每次执行一个写命令都会同步发送给slave,保持master与slave之间数据的一致性
哨兵模式
哨兵模式基于主从复制模式的,只是引入了哨兵来监控和自动处理故障。
哨兵的功能
Cluster模式
哨兵模式解决了主从复制模式故障不可自动转移、达不到高可用的问题,但还是存在难以在线扩容的问题的,Redis受限于单机配置的问题,Cluster模式实现了redis的分布式存储,即每台节点存储不同的内容。来在线解决扩容的问题。
对于大数据量的存储,尤其是持久化存储,通过分片,可以将数据存储到不同的节点上,通过降低单服务数据量级来提升数据处理的效能,从而达到拥有数据处理横向扩展的能力。
内库数据 -> 硬盘 : 重用数据 -> 为了防止系统故障而将数据备份到一个远程位置
RDB持久化
Redis可以通过快照来获得存储在内存里面的数据在某个时间节点上的副本。
Redis使用fork函数复制一份当前进程的副本(子进程),父进程继续接收来自客户端的请求,子进程开始将内存中的数据写入到硬盘,当子进程写入所有数据后会使用该临时文件替换掉旧的RDB文件,至此一次快照操作完成。
使用RDB快照的方式,一旦Redis异常退出,就会丢失最后一次快照以后更改的所有数据。这就需要开发者集合实际场景,通过调整配置规则来将可能发生丢失的数据控制在能接受的范围。
AOF持久化
一般需要打开AOF持久化来降低进程终止导致的数据丢失。AOF可以将Redis执行的每一条命令追加到硬盘文件中,这一过程显然会降低Redis的性能,但是大部分情况下这个是可以接受的。另外使用读写速度比较快的硬盘可以提高AOF的性能。
默认情况下,redis没有开启AOF持久化,AOF的实时性更好,可以通过appendonly yes开启AOP持久化机制。
开启AOF持久化之后每执行一条会更改Redis中数据的命令,Redis就会将该命令写入硬盘中的AOF文件,保存位置和RDB一样,都是通过dir设置的。默认的文件名为appendonly.aof,可以通过appendfilename参数修改。
每次执行更改数据库内容的操作时,AOF都会将命令记录在AOF文件中,但是事实上,由于操作系统的缓存机制,数据并没有真正的写入到硬盘中,而是存在了操作系统的硬盘缓存中,操作系统每隔30秒回执行一次同步,将缓冲中的数据同步到硬盘中。在这30的过程中,如果Redis服务中途异常退出,就会导致前30秒到现在的数据丢失的情况,因此Redis支持自定义AOF持久化的策略,修改appendfsync即可,如下所示。
为了兼顾数据和写⼊性能,⽤户可以考虑 appendfsync everysec 选项 ,让 Redis 每秒同步⼀次AOF ⽂件,Redis 性能⼏乎没受到任何影响。⽽且这样即使出现系统崩溃,⽤户最多只会丢失⼀秒之内产⽣的数据。当硬盘忙于执⾏写⼊操作的时候,Redis 还会优雅的放慢⾃⼰的速度以便适应硬盘的最⼤写⼊速度。
打开appendonly.aof文件可以看到,里面其实是有冗余的数据的,那么随着我们执行的命令增多,appendonly.aof文件会越来越大,而这些冗余数据是我们不需要的,这时候就可以采取重写的方式优化这个文件,如果不借助Redis自身的配置,那么也可以使用命令BGREWRITEAOFD手动重写AOF文件。
如果是自动重写,则默认的配置是 auto-aof-rewrite-percentage 100,表示的是目前的AOF文件超过上一次AOF文件的百分之多少时会发生重写。
AOF和RDB搭配使用
一般情况下我们都采取AOF和RDB同时开启的方式,这样既可以保证数据的安全性,也可以保证备份工作的顺利进行,此时重启Redis后Redis会采用AOF的方式来进行数据恢复,因为AOF方式的持久化机制丢失数据的概率小。
Redis可以通过MULTI、EXEC、DISCARD和WATCH来实现事务的功能,使用了MULTI命令之后可以输入多个命令,Redis不会立即执行命令,而是将他们放入队列,当调用了EXEC命令将执行所有命令。
Redis是不支持rollback的,所以不满足原子性的,也不满足持久性
总的可以理解为Redis事务提供了一种将多个命令请求打包的功能,然后在按照顺序执行打包所有的命令,并且不会被中途打断。
纵向扩展:升级单个Redis实例的配置,包括增加内存容量、增加磁盘容量、使用更高配置的CPU。考虑硬件容量和成本,且没办法解决主线程阻塞的问题,除非不持久化。
横向扩展:增加Redis实例的个数,相同配置的个数。
惰性删除:只有在取出key的时候才对数据进行过期检查,对CPU最友好,但是可能会导致大量的过期key没有被删除。
定期删除:每隔一段时间抽取一批key进行检查并删除,Redis 底层会通过限
制删除操作执⾏的时⻓和频率来减少删除操作对CPU时间的影响,定期删除对内存更加友好,惰性删除对CPU更加友好。两者各有千秋,所以Redis 采⽤的是 定期
删除+惰性/懒汉式删除。
4.0版本后增加了两种
Redis是基于Reactor模式设计的一套高效的事件处理程序,这套事件处理模型对应的是redis中的文件事件处理器,由于文件事件处理器是单线程的。所以说redis是单线程的。
既然是单线程,那么redis是如何监听大量的连接的?
Redis通过IO多路复用原理来监听大量的客户端连接,它会将感兴趣的事件以及类型注册到内核中监听事件是否发生。
IO多路复用技术让redis不需要创建多余的线程来监听客户端的连接,降低了资源的消耗。
Redis4.0以后就开始支持多线程了。
但是多线程主要是用来解决大键值对的删除操作,这些操作就会使用其他的线程来进行操作,就不会造成阻塞。
为什么不使用?
命令方式:
自增:INCR key
自减:DECR key
Java的方式
自增:increment
自减:decrement
阻塞式IO
阻塞式IO指的是一旦输入/输出工作没有完成,程序就处于阻塞状态,直到输入输出工作的完成。
非阻塞式IO
非阻塞式IO也并非完全非阻塞,通常都是通过设置超时来读取数据的,未超时之前,程序阻塞在读取函数上;超时后,结束本次的读取,将已经读取到的数据返回。通过不断循环的读取,最终就能读取到完整的数据。
双写一致性策略
缺点:如果更新缓存成功,但是数据库更新失败,会造成缓存脏数据
缺点:高并发场景下,假设有线程A和B,线程A先更新数据库,还没来得及更新缓存,线程B就更新数据库并更新缓存,然后线程A才更新缓存,这就造成了线程B的更新丢失。
缺点:高并发场景下,线程A先删除缓存、准备更新数据库,这时线程B读取数据,发现缓存已经被删除了,所以到数据库读取数据,读取完毕之后再把数据写入缓存,这时候线程A更新数据库,导致缓存和数据库数据不一致。
解决方案:线程A更新完数据库之后,再删除一次缓存,也叫延迟双删。
缺点:线程A读取数据库,然后准备准备写redis,这时候线程B更新数据库,并删除缓存,然后线程A才把数据写进去,这就导致了旧数据还存在于缓存。
解决方案:线程A再删除一次缓存、延迟双删
客户端和Redis使用TCP连接,无论客户端向Redis发送命令还是Redis向客户端返回执行结果,它们都需要经过网络传输,这两个部分的总耗时称为往返延时。
Redis的底层通信协议对管道提供了支持,通过管道可以一次性发送多条命令并在执行完之后一次性将结果返回,当一组命令中的每条命令都不依赖于之前命令的执行结果时就可以将这组命令通过管道一起发出。管道通过减少客户端与Redis的通信次数来实现降低往返时延的目的。
@ResponseBody的作用是将Controller的方法返回的对象通过适当的转换器转换为指定的格式之后,写入HTTP响应的body中,通常用来封装JSON或者XML数据,返回JSON数据的情况比较多。
IOC(控制反转)是一种设计思想,就是将原本在程序中手动创建对象的控制权,交给Spring框架来管理,IOC容器是Spring 用来实现IOC的载体,IOC实际上就是一个Map,Map中存放的各种对象。
IOC的初始化过程
XML -> Resource -> BeanDefinition -> BeanFactory
AOP能够将那些与业务无关、却为业务模块共同调用的逻辑或者责任封装起来(例如日志处理、事务处理、权限控制等),便于减少系统的重复代码,降低模块之间的耦合度,并有利于未来的拓展性和可维护性。
SpringAop就是基于动态代理的,如果要代理的对象,实现了某个接口,那么SpringAOP会使用JDK代理,去创建代理对象,而对于没有实现接口的对象,就无法使用JDK Proxy去进行代理了,这时候SpringAOP会使用Cgliba生成一个被代理对象的子类作为代理。使用AOP之后我们可以把一些通用的功能抽象出来,在需要用到的地方直接使用即可,这样就大大简化了代码量,我们需要增加新功能时也方便,提高了系统的扩展性。日志功能、事务管理等等场景都用到了AOP。
事务隔离级别有哪些?
(1)、DEFAULT: 使用后端数据库默认隔离级别
(2)、读未提交
(3)、读已提交
(4)、可重复读
(5)、串行化
Spring事务的传播行为有哪些?
如何理解@Transcation注解?
既可以作用在方法上,也可以作用在类上,比如@Transcation(rollbackFor = Exception.class)表示的就是当遇到运行时异常和非运行时异常时都要进行回滚操作。如果是@Transcation不指定rollbackFor,那么只有在遇到RuntimeException时才回滚,非运行时异常不回滚。
在spring中,万物都是bean对象,每一个对象都可以封装成BeanDefinition,然后去生成bean对象。
第一步,spring要找到哪些bean需要实例化:
第一种是xml的方式,如果需要实例化bean就在xml中配置bean标签,找到所有需要创建的bean
第二种注解方式,扫描所有添加了spring注解的bean,把所有的bean封装成一个BeanDefinition放入一个list.
第二步,循环list,通过BeanDefinition中的类全名称,通过反射进行实例化,属性注入,如果还有一个初始化的动作,也可以在属性注入后做,比如:init-method方法,比如实现了InitializingBean这个接口,然后在初始化的时候自动调用afterPropertiesSet该方法,我们可以在这个里面对bean做其他的操作,如果bean需要被代理,则通过后置通知,去生成代理的bean,如果bean实现了接口就使用jdk代理,如果没有实现就使用cglib,如果配置的优先级,则优先使用cglib.
第三步,完成后就将bean放入到spring的一级容器中。
${} 是Properties文件中的变量占位符,可以用于标签属性值和sql内部,属于静态文本替换
#{} 是sql的参数占位符,MyBatis会将 sql中的 #{}替换为?号,在sql执行之前会使用PreparedStatement的参数设置方法,按序给sql的?号占位符设置参数值。
Dao接口也就是常说的mapper接口,接口的全限名就是xml里面的namespace的值,接口的方法名,就是映射文件中的MappedStatement的id值,接口方法内的参数,就是传递给SQL的参数值,Mapper接口是没有实现类的,当调用接口方法时,通过全限定名 + 方法名拼接字符串作为key,可以唯一定位一个MappedStatement,例如StudentDao.findStudentById,可以唯一找到namespace为StudentDao下面id =findStudentById 的MappedStatement,在MyBatis中都会被解析为一个MappedStatement对象。
Dao接口里面的方法是不能被重载的,因为是全限名 + 方法名的保存和寻找策略
Dao接口的工作原理是JDK动态代理,MyBatis运行时会使用JDK动态代理为Dao接口生成proxy对象,代理对象Proxy会拦截接口方法,转而执行MappedStatement所代表的sql,然后将sql执行结果返回。
MyBatis使用的是RowBounds对象进行分页,它是针对ResultSet结果集执行的内存分页,而非物理分页,可以在SQL内直接书写带有物理分页的参数完成物理分页功能,也可以使用分页插件完成物理分页。分页插件PageHelper
分页插件的基本原理是使用MyBatis提供的插件接口,实现自定义插件,在插件的拦截方法内拦截待执行的SQL,然后重写SQL,添加对应的物理分页语句和物理分页参数。
逻辑分页
逻辑分页依赖的是程序员编写的代码。数据库返回的不是分页结果,而是全部数据,然后再由程序员通过代码获取分页数据,常用的操作是一次性从数据库中查询出全部数据并存储到List集合中,因为List集合有序,再根据索引获取指定范围的数据。
物理分页
物理分页依赖的是某一物理实体,这个物理实体就是数据库,比如MySQL数据库提供了limit关键字,程序员只需要编写带有limit关键字的SQL语句,数据库返回的就是分页结果
MyBatis动态sql可以让我们在Xml映射文件中,以标签的形式编写动态SQL,完成逻辑判断和拼接SQL的功能,Mybatis提供了9种动态SQL标签
Trim 、where、foreach、choose、when、otherwith、bind、set等
原理为使用OGNL从SQL参数对象中计算表达式的值,根据表达式的值动态拼接SQL,以此来完成动态SQL的功能。
有了列名和属性名的映射关系后,MyBatis通过反射创建对象,同时使用反射给对象的属性逐一赋值并返回,那些找不到映射关系的属性是没办法完成赋值的。
SpringBoot能够实现快速开发、快速整合、配置简化、内嵌服务容器等
启动类上注解是@SpringBootApplication,也是SpringBoot的核心注解,包含了以下三个注解
@SpringBootConfiguration: 组合了@Configuration注解,实现配置文件的功能
@EnableAutoConfiguration:打开自动配置的功能
@ComponentScan:Spring组件扫描注解
Spring Boot Starter、SpringBoot jpa都是 “约定大于配置”的一种体现,都是通过约定大于配置的设计思路来设计的,SprinBootStarter在启动过程中会根据约定的信息对资源进行初始化。
框架提供的默认值会让我们的项目开发起来更有效率,如果默认值满足不了我们的需求,我们可以使用Properties配置文件和YAML配置文件来重写默认值来满足我们的需求,所以约定大于配置。但是这并不是一种新的思想,在JDK5.0发布时,采用元数据、引入注解的概念,就代表了简化配置的开始,这就是初期的一种“约定优于配置”的体现,所以 “约定优于配置”这一设计理念,从Spring的注解版本就已经开始了,引入注解就是为了减少一些默认配置,引入注解也就代表着简化配置的开始,官方说基于Spring的基础就是这个事实。
总结下来就是启动的时候,按照约定去读取Spring Boot Starter的配置信息,再根据配置信息对资源进行初始化并注入到Spring容器中,这样SpringBoot启动完毕后就已经准备好一切资源,使用的过程中直接注入Bean资源即可。
其他面试题:Spring Boot 面试,一个问题就干趴下了! - 纯洁的微笑 - 博客园
单点执行、故障转移、服务状态
缺点:单点故障其问题,如果这个点挂了那么就不存在定时任务了
缺点:单点故障问题,这台服务器发生故障就没办法了
缺点:数据库负担增大
优点:利用redis的自动过期机制实现了转移故障机器的问题,而且redis访问速度快
缺点:没有事务管理机制,访问redis时一定会出现高并发的情况。
Redis 分布式锁的最基本原理
Redis 有一个SETNX命令,该命令的功能如下,SETNX key value,将key的值设置为value,当且仅当 key不存在时,若给定的key已经存在,则SETNX不作任何操作,SETNX是 【SET if Not Exists】(如果不存在则SET的缩写),这样的话tomcat1、tomcat2、tomacatN就可以以谁最新执行了SETNX命令来作为有没有抢到锁的依据了,这就是能够使用redis做分布式锁的最基本原理。
参考文章:https://blog.csdn.net/nrsc272420199/article/details/106441612
1. 悲观锁解决商品超卖问题
synchronized关键字
2. 乐观锁的方式解决超卖问题
根据版本号判断是否还能修改库存,利用了MySQL的排他锁,将并发交给MySQL数据库去处理
1、首先查询商品库存剩余量
2、扣减库存并且更改版本号,条件是 商品id = 传入的id,版本 = 上一步查询商品信息得到的版本(0)
3、新增订单
比如现在有1000个线程在1秒钟内涌入,这是一个并发操作,由于在服务层没有加任何的锁,所以会有很多的线程查询到商品剩余量并且开启事务即将要执行 update 操作(扣减库存),
由于MySQL的默认隔离级别是可重复读,而几乎所有的线程都是同一时刻开启事务,所以开启事务时的视图读取到的数据就是version = 0的记录,
即使前面的线程事务已经提交,后面阻塞的线程读取到的数据也都是开启事务时读取到的数据(可重复读隔离级别下视图创建后中途是不会改变的),除非发生幻读(这种概率几乎没有),
只有等前面的线程提交事务释放锁资源后,后面的线程的事务才能提交(更改商品库存值),直到商品卖完了。
这就是乐观锁解决商品超卖问题的基本原理。
高并发系统的三大利器:缓存、降级、限流
接口限流
限流:对某一时间窗口内的请求数进行限制,保持系统的可用性和稳定性,防止因流量暴增而导致的系统运行缓慢或者宕机
在面临高并发的抢购请求时,如果我们不对接口进行限流,就会有大量的抢购请求调用下单接口,过多的请求打到数据库上会对系统造成一定的影响。
限流的目的是通过对并发访问的/请求进行限速,或者是对一个时间窗口内的请求进行限速的来保护系统,一旦达到限制速率则可以拒绝服务、排队或者等待
限流算法
漏斗算法、令牌桶算法(短时间内可以拿到大量令牌,而且消耗不是很大)
Google的开源项目Guava的RateLimiter就实现了令牌桶控制算法
令牌桶 + 乐观锁实现限流和超卖
隐藏秒杀接口 (单用户的防刷措施)
1. 我们在一定的时间内执行秒杀处理,不能在任意时间段内都接受秒杀,如何加入时间验证 (限时抢购)
2. 如何防止通过脚本进行抢购 (接口隐藏)
3. 秒杀开始之后如何限制单个用户的请求频率,即单个时间内限制访问次数 (单用户频率限制)
限时抢购
使用Redis记录秒杀商品的过期时间,对过期的请求拒绝
键(key) : 比如kill + 商品id
秒杀接口地址的隐藏
每次点击秒杀按钮,才会生成秒杀地址,在这之前是不知道秒杀地址的,因为地址不是写死的,是从服务端获取,动态拼接而成的地址,安全校验电还是要放在服务端,禁止掉这些恶意服务
思路如下
1.用户点击秒杀按钮,在进行真正的秒杀之前,先去请求一个服务端地址,这个请求用于生成动态的秒杀地址,传参为用户id和商品id,在服务端拼接用户id和商品id,作为hashKey,然后生成MD5值,
最终存入redis并设置过期时间,最后把生成的MD5值返回给前端。
2.前端拿到服务端生成的MD5之后,在用这个值拼接在URL上的作为参数,去请求后端的秒杀接口
3.后端接收到这个秒杀请求后,取出缓存中的md5值与请求参数中的对比,如果相同则执行下面的秒杀逻辑,如果不相同则直接视为非法请求
该操作可以防止恶意用户登录之后,获取到token的情况下,通过不断调用秒杀地址接口,来达到刷单的恶意请求
但是这种情况仍然不能解决利用按键精灵或者机器人频繁点击的操作,为了降低点击按钮的次数,以及高并发下,防止多个用户在同一时间内并发出大量请求,加入数学公式图形验证码等措施。
注意:
1.在获取MD5之前必须先判断一下秒杀活动是否开始,否则活动未开始也照样可以请求秒杀,或者是在请求秒杀接口前判断活动是否开始。
2.加验证码是为了防止连点器这种操作,而加随机秒杀地址是为了避免不通过页面点击而直接刷接口(只有通过点击才能获取地址)
synchronized锁在Spring事务管理下,为啥还是线程不安全?
在Spring的事务管理下,假设现在有一个线程执行但是还没有提交事务,然后另外一个线程开始读取数据并执行,由于第一个线程还没有提交,导致第二个线程读取到的不是最新的数据,所以导致线程不安全问题。
总结就是Spring事务的锁的范围比synchronized大很多
解决方案:
(1)、不加@Transaction注解 ,不推荐
(2)、更改事务隔离级别为 读未提交,不推荐
(3)、读取数据时使用当前读而不是快照读,比如使用 FOR UPDATE
(4)、在调用处加 synchronized
Redis分布式锁
分布式锁的本质就是占一个坑,当别的进程也要来占坑时发现已经被占用,就会放弃或者稍后重试
占坑一般采用的是 setnx(set if not exists)指令,只允许一个客户端占坑,先来先占,用完了再调用del指令释放坑
但是如果逻辑执行到中间出现异常,可能导致del执行失败,这样就会陷入死锁,锁永远没办法释放,为了解决这个死锁问题,我们可以再拿到锁时加上一个过期时间,这样的话即使发生死锁,当达到一定的时间后也会自动的释放锁
这样又有一个问题,setnx和expire是两条指令而不是原子指令,如果两条指令之间进程挂掉依然会出现死锁
为了治理上面乱象,在redis 2.8中加入了set指令的扩展参数,使setnx和expire指令可以一起执行
使用Redisson实现分布式锁
Redission这个框架也是重度依赖了Lua脚本和Netty
Lua脚本加锁逻辑
首先去判断这个锁存不存在
如果不存在,那么重入计数设置为1,并且设置过期时间
如果锁存在,并且唯一标识匹配,那么重入计数 + 1,并再次设置锁的过期时间
如果锁存在,但是唯一表示不匹配,那么说明这个锁被其他线程占用,当前线程是没办法解别人的锁的,直接返回过期时间
Lua脚本解锁逻辑
首先判断锁是否存在
如果不存在,则直接广播解锁消息并返回1
如果锁存在,但唯一标识不匹配:则表明锁被其他线程占用,当前线程不允许解锁其他线程持有的锁
若锁存在,且唯一标识匹配:则先将锁重入计数减
整体的流程
1、线程A和线程B两个线程同时争抢锁。线程A很幸运,最先抢到了锁。线程B在获取锁失败后,并未放弃希望,而是主动订阅了解锁消息,然后再尝试获取锁,顺便看看没有抢到的这把锁还有多久就过期,线程B就按需阻塞等锁释放。
2、线程A拿着锁干完了活,自觉释放了持有的锁,于此同时广播了解锁消息,通知其他抢锁的线程再来枪;
3、解锁消息的监听者LockPubSub收到消息后,释放自己持有的信号量;线程B就瞬间从阻塞中被唤醒了,接着再抢锁,这次终于抢到锁了!后面再按部就班,干完活,解锁
1. 后台添加一个代金券秒杀活动到活动表,同时将这个活动存放在redis缓存中,活动id拼接作为key,同时根据开始时间和结束时间设置过期时间
2. 活动开始后用户点击秒杀按钮,首先将验证码和代金券id以及用户id传给后台,验证验证码,后台根据商品id和用户id生成一个MD5的path,并存入redis,返回给前端(接口隐藏)
3. 前端获取到path,将其作为其中一个参数,然后请求秒杀接口,后台首先验证这个活动是否开始,如果在进行中,那么根据path去对比redis中的path,如果一致,继续扣减库存,否则返回非法请求
4. redis中扣减库存后,将消息塞入消息队列中,利用RocketMQ的异步消息来更新数据库中的库存,下单。以此减轻了数据库的压力。这里引用了事务消息的机制来保证缓存和数据库中数据的一致性。我们需要保证redis扣减库存和发送消息这两个操作的原子性,RocketMQ4.3 之后实现了完整的事务消息。将本地事务消息移动到了MQ内部,在事务提交之前消费者是不可见,等本地事务完成后,会告诉MQ内部消息可以消费,然后消费者再去消费消息。
采用Redisson实分布式锁,采用RocketMQ实现分布式锁。采用令牌桶算法实现限流,采用隐藏接口地址的方法防止提前刷单,采用验证码的方式防止恶意刷单。
第一种方式,在迁移目标服务器跑一个迁移脚本,远程连接源数据服务器的数据库,通过设置查询条件,分块读取源数据,并在读取完之后写入目标数据库。这种迁移方式效率可能会比较低,数据导出和导入相当于是一个同步的过程,需要等到读取完了才能写入。如果查询条件设计得合理,也可以通过多线程的方式启动多个迁移脚本,达到并行迁移的效果。
第二种方式,可以结合redis搭建一个“生产+消费”的迁移方案。源数据服务器可以作为数据生产者,在源数据服务器上跑一个多线程脚本,并行读取数据库里面的数据,并把数据写入到redis队列。目标服务器作为一个消费者,在目标服务器上也跑一个多线程脚本,远程连接redis,并行读取redis队列里面的数据,并把读取到的数据写入到目标数据库。这种方式相对于第一种方式,是一种异步方案,数据导入和数据导出可以同时进行,通过redis做数据的中转站,效率会有较大的提升。
欠缺知识