Freeman's Blog

一个菜鸡心血来潮搭建的个人博客

0%

东方财富

用户角度

  • 首先是一个互联网财经金融媒体,可以为投资者提供实时的财经资讯、市场行情咨询、投资产品的报价等。
  • 运营有投资者互相交流的社区股吧,用户可以在股吧进行互动交流心得。
  • 是一个金融交易平台,东方财富获得有一些基金销售牌照、期货经纪业务牌照等金融产品交易的牌照,用户可以直接通过东方财富提供的平台进行交易。
  • 提供专业的金融数据服务,为用户提供财经数据、金融信息和分析结果等全方位的数据支持,也可以让用户实现个性化的信息查询和统计分析等需求。
  • 一站式服务,东方财富这一平台可以实现用户的资讯获取、与其它投资者交流、数据分析和实际交易操作等需求。

企业角度

  • 金融咨询和股吧可以积累用户流量,积累用户基数。
  • 为用户提供交易服务,收取交易佣金。
  • 拥有庞大的用户基数,为合作伙伴提供广告投放服务
  • 为用户提供定制化、个性化服务:投资顾问、数据服务

自我介绍

面试官您好我叫黄炽丰,本科毕业于华南理工大学数学学院信息管理与信息系统专业,现就读于南京大学软件学院电子信息专业(软件工程方向)。我本科期间参与了一些与计算机相关的课程,对计算机软件开发产生了一定的兴趣。于是在本科期间除了理论课程方面的学习,我还参与到了百步梯学生创新中心这个校级学生组织的技术部门中,参与了一些校内的项目研发工作,在期间积累了一些开发方面的技能和团队合作开发的经验。不过我本科期间主要积累了前端开发方面的经验,在本科生涯的后期发现自己更喜欢后端开发一些,于是决定继续攻读研究生,在后端开发方面积累更多的知识和经验。研究生期间,我除了在实验室跟导师进行偏向理论方面的项目研究之外,还自行完成了一些课内外的实践项目,进一步积累Java后端方面的开发技能。现在我的求职方向是后端开发方面的工作职位,我的自我介绍就是这些,谢谢。

个人规划

  • 我对我自己的期望是成长为技术骨干类型的角色。
  • 首先我认为在工作的初期我会以技术积累为主。因为我对软件开发这个方向还是比较感兴趣比较喜欢的,如果希望在这个领域进一步发展,过硬的技术积累是必须的。
  • 其次我认为在工作了一定时间之后,我认为我会尝试理解我当前负责的项目或就职的公司的业务特征。我对业务系统的实现比较感兴趣,并且我认为在企业的技术部门中,技术是很难脱离业务而存在的。理解了业务的程序员才能够更好的开发出能解决问题和产生价值的软件,同时在项目管理和技术选型方面,面对一些决策时能够做出更合理的决策。

Hadoop

  • 大数据解决方案,分布式系统基础架构,主要包括HDFS和MapReduce。

HDFS

NameNode

  • Master节点
    • 负责管理HDFS中的文件元信息、例如目录树、各个数据节点的可用空间、每个文件的备份情况等等。
      • 目录树
      • 权限信息
      • 记录HDFS中有哪些块
      • 块到DataNode的映射
    • 负责监控各个DataNode的健康状况,如果发现无法连接到DataNode,会将该DataNode移出HDFS并重新备份该节点相关的数据,以满足集群中文件的备份数要求(默认每个文件每块都要有3个副本)。
  • 这些信息以两种形式保存在本地磁盘
    • fsimage: HDFS元数据镜像文件,相当于全量备份
    • editlog: HDFS文件改动日志,相当于增量备份(对执行的修改进行备份)

Secondary NameNode(Hadoop 1.X的思路)

  • 定期合并fsimage和editlog并传输给NameNode,以减小editlog的体积。Secondary NameNode为了减轻NameNode的负担会帮其完成这项工作。
  • 可以作为备份节点作为NameNode的元数据进行热备份
    • 云玩家方案:定期备份,但是两次备份期间的修改会被丢失
    • 真实方案:的确是定期备份。。。
      • 每隔1小时会进行一次合并,editlog中的事务条数达到某个数字也会进行一次合并。

        合并fsimage和editlog的流程

  • 生成一个新文件edits.new,用于记录合并过程中产生的日志信息
  • Secondary NameNode从NameNode上读取edits文件和fsimage文件,并进行合并操作,生成一个fsimage.ckpt文件。
  • 将生成的合并后的文件发送到NameNode上,让NameNode替换原本的fsimage
  • edits.new成为新的edits文件。

Standby NameNode + 共享存储(since Hadoop 2.X)

  • 只有主NameNode可以对外提供读写服务,StandBy NameNode作为备份。
  • ZKFailOverController:检测到NameNode的健康状况,在主NameNode故障时,借助Zookeeper实现自动的主备选举和切换。
  • ZK集群:提供选举支持
  • 共享存储系统:保证Active NameNode和Standby NameNode能够实现元数据同步。主备切换时新的主NameNode需要确认元数据能够完全同步。

QJM(Quorum Journal Manager)

  • HDFS的默认共享存储方案
  • ActiveNameNode每次写Editlog时都需要向JournalNode集群的每一个JournalNode发送写请求,让每个JournalNode写入相同的内容,只要大多数JournalNode节点返回成功就认为向集群写入EditLog成功。对于2N + 1台JournalNode,最多可以有N台机器挂掉。剩下的N + 1台机器如果能够保持一致,仍然能够构成“大多数”。
  • 向JournalNode提交Editlog是同步阻塞的,但是只需要接收到大多数JournalNode的返回就可以了。
  • 如果没有收到大多数的Editlog返回,则认为提交EditLog失败了。(如果借鉴Paxos算法的经验,此处应该有一个版本号,提交失败的时候,说明当前NameNode的数据版本可能落后于JournalNode集群,不应该继续对外提供服务了)此时NameNode会停止服务。
  • Editlog的分代和分段同步:每个JournalNode只会接收比自身代数大的Editlog同步请求。
    • 增量同步:存在跨度问题,如果本地和最新版本差了不止一代,只添加一个增量也是错误的EditLog
    • 全量同步:每次都传一个最新版本的EditLog,EditLog文件可能会很大。
    • 分段全量同步:fsimage:当前元信息的完整快照。Active NameNode定期完成fsimage和editlog的合并(借助Secondary NameNode),将旧的editlog截断并只同步最新一段的editlog。Standby NameNode也需要进行fsimage和editlog的合并
  • 主备切换
    • ZK方式:让NameNode和ZK维持会话,使ZK能够监控NameNode的健康状况。发生主备切换时,多个StandBy NameNode可能会竞争成为Active NameNode,此时让ZK支持主节点的选举(本质是获得一个分布式锁?)。
    • 只有代数高的节点能够对元信息进行写,但是客户端有可能读到旧的信息。HDFS提供fencing机制,当完成主备切换时,使用一定的机制让原本的Active NameNode停止对外服务。默认是通过ssh的方式发送一条命令,杀掉原本的NameNode进程。

StandBy NameNode

  • Standby NameNode会从JournalNode定期同步editlog,当需要转换为NameNode时需要将落后的Editlog补回来。

DataNode

  • 完成实际的数据存储,并向NameNode定期发送心跳便于双方感知对方存在。HDFS以固定大小的块存储文件内容。文件会被切分为若干块存储到不同的DataNode中,同时会将同一个块写到3个不同的DataNode上。文件切割需要由客户端完成,但是文件块的复制对用户透明。
  • DataNode把每个数据块存在单独的文件中。它不在同一个目录创建所有的文件,而是通过试探的方法确定每个目录的最佳文件数目。
  • DataNode的状态报告:本地文件对应HDFS数据块的列表,作为报告发送到NameNode。

HDFS上传文件的流程

  1. Client向NameNode请求数据上传,先告知NameNode自己需要上传的元数据信息(包括大小吗)
  2. NameNode对上传文件的请求进行检查。例如重名校验和权限校验等。如果检查通过,NameNode会写Editlog,然后修改内存中的元数据信息。
  3. NameNode向客户端响应可以上传文件。
  4. Client在本地将文件进行分割,分割成固定大小的块。按顺序向NameNode请求上传块。
  5. NameNode向Client返回可以上传的节点信息。节点信息是顺序返回的。
  6. Client向返回的DataNode发出传输通道建立请求。第一位的DataNode在收到通道建立请求后也会递归地向下一位DataNode发送通道建立请求。之后DataNode递归地应答通道建立请求,收到所有的通道建立应答后,第一为的DataNode会返回信息示意Client可以开始传输文件。
  7. Client会将每个Block上传到通道第一位的DataNode上,通道中的DataNode按顺序完成Block的复制。(这里不清楚是同步的还是异步的,但是肯定会有确认)。
    • 想要异步复制,可以设置dfs.replication.min,只让最低限度的DataNode先建立通道并完成复制。然后再让NameNode检测到该块未达到复制因子时决定DataNode怎么复制。
  8. 重复以上流程,Client将所有分块传输完毕会向NameNode进行报告。

HDFS下载文件的流程

MapReduce

  • 同样使用Master-Slave架构:JobTracker - TaskTracker。Slave节点向Master节点发送心跳消息以让双方感知对方存在。
  • 只有Map和Reduce函数,更高级的计算范式需要用户手动根据Map和Reduce进行构造。
  • 所有的计算中间结果都需要保存在HDFS上,计算过程需要反复地读写磁盘。

Spark

  • 主要提供一个全面、统一的框架来管理有不同性质的数据集和数据源的大数据处理需求。
  • 更多的计算范式,不局限于Map和Reduce
  • 优化的磁盘使用方式:相比于Hadoop MapReduce,尽可能少地读写磁盘。(只有Shuffle的时候将数据完全存放在磁盘?)
  • DAG计算模型:根据RDD的依赖关系先计算得到DAG,如果不涉及与其它节点进行数据交换(Shuffle),Spark可以尽可能地在内存中完成这些操作。(减少不了Shuffle,这个是由计算的依赖关系决定的)
  • Spark Core:定义RDD的API,操作。
  • Spark Streaming: 允许程序像处理普通RDD一样处理实时数据(流数据)
  • Spark GraphX:控制图、并行图操作和计算的一组算法和工具的集合。

Cluster Manager

  • Master节点,控制整个集群,监控Worker

    Worker:控制计算节点,启动Executor或者Driver

    • Driver:运行Application的main函数
    • Executor: 为某个Application运行在Worker Node上的一个进程。

Spark计算模型

RDD

特点

  • 最小的计算单元
  • RDD不会将所有的中间结果存放在硬盘上,只会在内存不足时将部分中间结果存放在内存。
  • 弹性:
    • 存储的弹性:内存和磁盘可以进行自动切换
    • 容错:数据丢失可以自动回复(Linage?)
    • 计算:计算出错可以重试
    • 分片:根据需要重新分片
  • 分布式:数据存储在集群的不同节点
  • 封装计算逻辑而不是持有数据
  • 不可变:逻辑不可改变,如果需要执行新逻辑需要创建新RDD。(转换…)
  • 可分区、并行计算。

核心属性

  • 分区列表:用于执行任务时进行并行计算
  • 分区计算函数:每个分区的数据不同,但是计算逻辑是一样的。计算逻辑对每个分区的数据进行计算
  • 依赖关系:依赖的所有RDD
  • 分区器:可选、决定对数据进行分区的方式
  • 首选位置:确定计算发送给哪个节点。(让效率最优)
    • 移动计算胜过移动数据

创建

  1. 从HDFS/与HDFS兼容的其它持久化存储系统如Hive、Canssandra、HBase输入创建
  2. 从本地文件创建
  3. 从父RDD转换得到新RDD
  4. 通过parallelize或makeRDD将单机数据转换为RDD

算子

  • Transformation:延迟计算,等到Action操作才会触发计算。

常用命令

进程管理

ps

  • 查看进程信息
    • ps -aux/ps -ef:查看所有进程
    • -p [pid]: 查看指定PID的进程

      top/htop

  • 动态地展示Linux进程运行情况。包括PID、用户、内存占用率、CPU占用率、启动命令等信息。

pstree

  • 将所有进程以树状图显示。

虚拟内存管理

vmstat

  • 查看虚拟内存状态

    进程状态procs

  • r:运行队列中的内核线程数目
  • b:等待队列中的内核线程数目

    内存状态memory

  • swpd: 虚拟内存大小(k)
  • free: 空闲内存大小(k)
  • buff: 已经使用的buff大小(读写缓冲区)
  • cache: 已经使用的cache大小
  • inact: 非活跃内存大小,可以回收的内存
  • active: 活跃内存大小

    交换空间状态swap

  • si: 每秒从交换区写入内存的大小
  • so: 每秒从内存写到交换区的大小

    IO

  • bi:每秒读取的块数(包括磁盘在内的所有块设备)
  • bo:每秒写入的块数(包括磁盘在内的块数量)

    系统调用情况system

  • in: 每秒中断数,包括时钟中断
  • cs:每秒上下文切换数:系统调用和进程/线程切换需要进行上下文切换。值太大时要考虑减少进程或线程的数目

    CPU(单位百分比)

  • us:用户进程消耗CPU时间
  • sy:系统进程消耗CPU时间
  • id:空闲时间
  • wa:等待IO时间

网络相关

lsof

  • List Open Files, 展示打开的文件。文件可以是一个普通文件,一个目录,一个库,一个网络文件(例如socket)
    • -i [地址]:列出所有文件,IP地址和指定参数匹配。如果不指定参数,则选出所有的网络文件。
      • [4/6][protocol][@hostname|hostaddr][:service|port]:可以指定过滤参数。[4/6]指IP版本
    • -p [pid]: 查看指定进程已经打开的文件
    • -c [command]: 选出指定命令正在使用的文件

netstat

  • 显示网络连接、路由表、接口信息、多播成员等。
    • -t: 只显示TCP连接
    • -u:只显示UDP连接
    • -l:只展示listening的socket
    • -a:展示listening和non-listening的socket
    • -n:显示数字地址(numerical address)
    • -p:显示socket所属的PID和进程的名称

动态代理

  • 静态代理的麻烦:要为每一个实现类单独写一个代理类。

    JDK动态代理

  • 对于实现了某个接口的类,能够直接生成接口的的代理对象(而不用写代理类)。
  • 基本原理:利用Java反射机制
  • 基本思路:
    1. 接口有方法的定义,但是没有方法体,也没有构造器。通过反射的某个方法根据接口的方法信息创建一个有相同类结构信息但是有构造器的某个类。 -> java.lang.reflect.Proxy类,提供静态方法getProxyClass(ClassLoader loader, Class<?> ... interfaces),只要提供一个类加载器和接口(数组),就可以生成一个代理类的Class对象。而这个代理类是有构造器的(可以通过Class对象调用getConstructors())。该类没有无参构造器,需要传入实现InvocationHandler的类实例。
    2. 接口的方法体为空,在代理对象中使用传入的实现了InvocationHandler的实例即可利用代理目标在代理对象中执行代理目标的方法,并在代理目标的方法调用前后进行额外的操作或控制。代理对象需要持有实现了InvocationHandler接口的实例。在代理对象上调用代理目标的方法时,代理对象会通过实现了InvocationHandler的实例,将调用转移到代理目标上。
      1
      2
      3
      4
      5
      interface InvocationHandler {
      ...
      Object invoke(Object proxy, Method method, Object[] args);
      ...
      }
      其中,proxy为代理对象本身(而不是代理目标)。method为调用的方法,需要对method指定执行方法的目标才可以通过该参数调用方法。args为传入的参数。总之,单独有实现了InvocationHandler接口的实例还是不能正常地调用目标对象的方法,我们需要设法让invoke方法中可以获得到目标对象的引用。方法之一是让实现了InvocationHandler的类持有代理目标的引用(可以用构造函数传参的方式)。
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      class MyInvocationHandler implements InvocationHandler {
      private final Object target;

      public MyInvocationHandler(Object target) {
      this.target = target;
      }

      @Override
      public Objcet invoke(...) {
      ...
      }
      }
      1. 更简单地获得代理对象的方法:Proxy.newProxyInstance(ClassLoader loader, Class<?>[] interfaces, InvocationHandler handler)
  • 优点:不用写代理类,直接生成代理对象。接口增加新方法时,代理对象可以不用进行修改。
  • 缺点:只能对实现了接口的类使用。在代理对象上只能调用接口中定义的方法。

CGLIB动态代理

  • CGLIB(Code Generation Library)是一个基于ASM的字节码生成库,它允许我们在运行时对字节码进行修改和动态生成。CGLIB 通过继承方式实现代理。很多知名的开源框架都使用到了CGLIB, 例如 Spring 中的 AOP 模块中:如果目标对象实现了接口,则默认采用 JDK 动态代理,否则采用 CGLIB 动态代理。CGLIB属于开源项目,需要额外依赖。
  • 基本思路:
    1. 自定义MethodInterceptor并重写intercept方法。该方法用于拦截增强代理目标的方法,类似于JDK动态代理的invoke
      1
      2
      3
      4
      5
      6
      public class MyInterceptor implements MethodInterceptor {
      @Override
      public Object intercept(Object o, Method method, Object[] args, MethodProxy methodProxy) {
      ...
      }
      }
      其中o是已经被增强的代理目标(the enhanced object),method是被增强的方法(Intercepted Method),args是方法调用的参数。methodProxy用于调用原始方法(super method, non-intercepted method)。
      1. 获得代理类
        1
        2
        3
        4
        5
        // 假设需要代理的目标类的Class对象是clazz
        Enhancer enhancer = new Enhancer();
        enhancer.setClassLoader(clazz.getClassLoader());
        enhancer.setSuperclass(clazz);
        enhancer.setCallback(new MyInterceptor());
  • CGLIB动态代理是通过生成一个代理目标的子类来拦截代理类的方法调用,因此不能代理声明为final的类和方法。

集合

集合概述

javaColletion

Comparable和Comparator

  • 不同的接口,Comparable的方法是compareTo(obj)Comparator的方法是compare(obj1, obj2)

集合底层数据结构

List

  • ArrayListVector: Object[]数组。Vector线程安全。
  • ArrayList:
    • 真正持有函数的成员为transient Object[] elementData
    • 无参构造时,为elementData赋予静态成员DEFAULTCAPACITY_EMPTY_ELEMENTDATA
    • 带参构造时,如果参数initialCapacity为0,那么为elementData赋予静态成员EMPTY_ELEMENTDATA。如果参数为正整数则为elementData初始化一个指定容量的新数组。其他情况则抛出异常。
    • 带参构造时,如果参数为空的集合(不是null),那么也赋予elementData静态成员EMPTY_ELEMENTDATA的值
    • 调用add方法时要调用ensureCapacityInternal方法保证数组容量足够。
    • 无论如何,如果最开始不显式指定容量或者使用非空集合创建ArrayListArrayList都会将创建DEFAULT_CAPACITY个元素这一行为推迟到向ArrayList加入第一个元素时。
    • ArrayList的扩容过程:
      1. 首先计算扩容后的最小容量。如果当前数据为DEFAULTCAPACITY_EMPTY_ELEMENTDATA,则扩容后容量至少为Math.max(DEFAULT_CAPACITY, minCapacity)DEFAULT_CAPACITY为10。否则扩容后容量至少为size + 1
      2. 如果minCapacity > elementData.length则需要扩容。扩容的总体规则是,如果旧容量的1.5倍大于minCapacity则扩容1.5倍,否则扩容到minCapacity。此时创建扩容后的新数组并把原本存放在elementData中的元素复制到新数组
      3. Java数组存在最大容量。最大容量一般稍微小于Integer.MAX_VALUEArrayList内部用MAX_ARRAY_SIZE保存一个稍微小于Integer.MAX_VALUE的值。如果出现了计算得到的新数组容量存在newCapacity - MAX_ARRAY_SIZE > 0的情况,则判断minCapacity有没有溢出。如果溢出了32位整数的取值范围,则抛出OOM异常,如果没有溢出,则新容量为(minCapacity > MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MAX_ARRAY_SIZE
      • 为什么要区分DEFAULTCAPACITY_EMPTY_ELEMENTDATAEMPTY_ELEMENTDATA?(since Java 8)
        • 为了区分 “我就是要一个初始容量为0的ArrayList(显示指定初始容量为0,使用空集合初始化ArrayList)” 和 “我想要一个初始容量为DEFAULT_CAPACITYArrayList,但是我不一定马上要用,因此先返回容量为0的数组,等我要用的时候(第一次加入元素的时候),再给我一次扩容上来” 。
  • LinkedList: 双向(不循环)链表

Set

  • HashSet: 基于HashMap实现,底层使用HashMap,使用成员对象来计算hashcode
    • 检查重复的方法:先计算hashcode值来判断对象加入的位置,同时与其它对象的hashcode值进行比较。如果发现相同hashcode值的对象,需要调用equals方法来检测对象是否真正相同。(hashcode的定义:同一个对象不可能有两个hashcode,不同对象的hashcode可能相同,其实就是“函数”的定义)
  • LinkedHashSet: 基于LinkedHashMap,能够按照添加的顺序进行遍历
  • TreeSet: 有序,能按照添加元素的顺序进行遍历,唯一,基于红黑树

Map(需要重新看)

  • HashTable:内部方法都是synchronized的,线程安全。
  • HashMap: 底层数据结构是数组+链表/红黑树。使用拉链法解决哈希冲突,JDK 1.8引入红黑树来优化过长的链表。HashMap会重新计算键的哈希值而不是使用hashCode。
    • 初始大小(capacity)为16,每次扩容时容量为原来的2倍。HashMap会让集合中的元素保持在一个合理的范围内(由装填因子load factor限制),如果元素数超过指定的阈值this.threshold时,则需要进行扩容并进行rehash。
    • HashMap总是使用2的幂作为哈希表capacity的大小。目的是为了加快通过hash计算KV对存储位置的速度。
    • 使用Key来计算hashcode。通过key的hashcode经过HashMap的扰动函数hash()处理(防止较差的hashCode()实现导致HashMap性能下降)后得到真正的hash值,然后通过(n - 1) & hashn为HashMap数组的长度,该运算等价于用hash对长度取余)的方式计算当前元素要存储的位置。
    • Table数组:hash表的桶(bucket)。数组的每个位置上都是一个链表。当链表长度大于阈值(默认为8)时,如果当前数组长度小于64,则进行数组扩容,否则将这个链表转换为红黑树。
      HashMap
      • 红黑树:
        • 首先是一种二叉搜索树:左子树小于根小于右子树
        • 根节点是黑色,叶子节点(Nil)是黑色
        • 每个红色节点的叶子节点都是黑色
        • 任意节点到每个叶子节点的路径都有数量相同的黑色节点
  • TreeMap实现了NavigableMap实现对集合内元素的搜索能力,实现SortedMap来实现对元素根据key进行排序的能力。
  • HashMap不是线程安全的

HashMap

初始化

  • 初始容量initialCapacity
  • 负载因子loadFactor
  • 阈值threshold: 键值对数超过这个值要扩容

查找

  • 需要用插入键值对的key的hashCode进行再次hash
    • hash方法的规则:如果key为null则返回0。否则计算hashcode ^ (hashcode >>> 16)
    • 目的:
      1. 对hashCode进行扰动,防止分布性不佳的hashCode影响HashMap的性能
      2. 当数组长度n比较小时可能只有hashCode的低位信息能参与计算,用hashcode的高16位和低16位进行异或,变相让hashcode的高位参与计算,提高了hash值的随机性。
  • (n - 1) & hash的方法将hash对数组长度进行求余,效率高。但这要求数组长度必须是2的幂

遍历

  • 迭代器HashIterator
    • 构造方法:找到第一个包含链表节点引用的桶
    • nextNode方法:如果当前节点非空,则返回的是当前节点,同时如果当前节点的next字段为空(当前桶没有下一个元素了),则寻找下一个包含节点引用的桶。

插入(重点)

插入操作总体逻辑
  1. 首先通过hash方法(hashcode ^ (hashcode >>> 16))计算出真正要使用的hash
  2. HashMap桶数组初始化可以延迟到第一次插入时。开始插入时首先判断table是否被初始化。如果没有则需要初始化。
  3. 通过(n - 1) & hash计算出桶的位置,如果此处还没有节点,直接新建节点存入该桶即可。
  4. 否则,开始查找该桶中存不存在Key相同的节点e
    • 如果节点是链表节点,则对链表进行遍历,找不到key相同的节点时将节点插入到链表的尾部。如果链表的长度在插入后大于等于树化阈值,则要将链表转换为红黑树。
    • 如果节点是树节点,则调用红黑树的插入方法。
    • 如果在定位插入位置的时候找到了Key相同的节点,则会返回Key相同的节点而不会进行插入。
  5. 判断要插入的键值对是否已经存在(e != null)。如果已经存在,则用新的值替换旧的值,并返回旧的值。否则,说明上一步进行过插入操作,如果插入后节点数量大于阈值,则需要对数组进行扩容。
链表树化机制
  • 如果只使用链表,当发生碰撞的次数过多性能会下降,JDK1.8引入红黑树来处理这个问题
  • 树化链表需要满足:
    1. 桶数组长度大于等于MIN_TREEIFY_CAPACITY = 64。原因可能是因为当桶数组容量较小时,键值对出现哈希冲突的概率会比较高,较容易导致链表长度较长。高碰撞率是因为桶数组容量较小时,应该有限扩容桶数组。并且桶容量较小时,扩容会比较频繁,而扩容时需要拆分红黑树并且进行重新映射。
    2. 链表长度大于等于TREEIFY_THRESHOLD = 8
  • 链表树化的比较依据(Key不要求实现Comparable接口):
    1. 比较key之间的hash大小,如果hash相同则继续
    2. 检测键是否实现了Comparable接口,如果实现则调用compareTo进行比较,否则继续
    3. 如果仍不能比较出大小,使用tieBreakOrder进行仲裁确定顺序
  • TreeNode节点仍保持了next引用和prev引用(链表型节点是单链表,TreeNode才会有prev引用),链表树化之后原链表的顺序会得到保留
桶数组扩容机制
  • 使用resize方法进行扩容
  • 计算出新数组容量和新阈值
  • 根据新容量创建新的桶数组
  • 将键值对节点重新映射到新的桶数组。如果节点是TreeNode类型,则需要拆分红黑树。如果是普通节点,则节点按原顺序进行分组。
  1. 计算出新数组容量和新阈值
    1. oldCap > 0时,桶数组table已经被初始化
      1. oldCap >= MAXIMUM_CAPACITY(2^30)时,不再扩容
      2. newCap < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY时,会通过乘以2的方式计算新阈值,此时新阈值newThr = oldThr << 1可能会溢出。(loadFactor可能大于1)
  2. 创建数组
  3. 键值对节点的重新映射:对于树形节点,需要先拆分红黑树再进行映射。对于链表型节点,需要先对链表进行分组再映射。
    • 扩容后的数组长度为原数组长度的两倍,并且数组的长度永远是2的幂
    • 对链表型节点的分组映射
      • 对于扩容前的长度m和扩容后的长度nn - 1m - 1在最高有效位上多一位1。
      • 举例:如果原容量为16,扩容后为32,那么16 - 1 = 15的二进制表示为0000 1111, 32 - 1 = 31的二进制表示为0001 1111
      • 因此可知扩容后,链表中的元素会根据第5位为1还是0被分成两组。使用m & hash(即oldCap & hash)的值即可判断元素应该属于哪一组
      • 遍历旧链表,根据当前元素oldCap & hash的计算结果是否为0,可以将链表中的旧元素使用尾插法插入两个新链表中,完成链表分组。
      • 最后将两条新链表分别确定插入位置后插入桶中即可完成扩容中的重新映射步骤。
        • 确定的方式:假设原链表所在的数组位置为index。对于新参与hash & (n - 1)计算的位为1的所有节点,该位的新结果必定为1。因此这些节点的新位置应该为index + m,即index + oldCap。相对的,对于该位为0的所有节点,扩容后hash & (n - 1)的计算结果会与原来保持不变,因此这些节点的位置不会改变。
    • 对树形节点的拆分和重映射
      • 树形节点的分组方式和链表型节点是一样的,也是通过oldCap & hash的方式确定分组。然后以遍历链表的方式(利用next引用)遍历红黑树,将树中的节点用尾插法插入到两个链表中。
      • 分别确定两个链表的新插入位置并将两个链表插入新位置。
      • 如果链表长度小于等于UNTREEIFY_THRESHOLD = 6,则将原本的红黑树转换为链表。
      • 否则,如果另一个链表不为空,说明当前链表中的一部分节点重新分组到了另一个链表,需要对当前链表重新进行树化。

删除

  1. 定位桶的位置
  2. 如果是TreeNode类型,用红黑树的查找逻辑定位到待删除节点,调用删除方法
  3. 如果是链表类型,找到待删除节点和它的前驱节点,删除待删除结点。

为什么桶数组table被transient修饰

  • HashMap不使用默认的序列化机制,而是使用readObject/writeObject两个方法定制了序列化和反序列化的过程。
  • 只要保存了键值对,桶数组可以根据键值对的数据重建HashMap
  • 由于装填因子的设置,多数情况下table无法被存满,保存table会浪费空间
  • 如果键对象没有重写hashCode方法,计算hash时需要使用Object类的hashCode方法,而该方法不同的JVM的实现可能不同。如果同一个键产生了不同的Hash,原本的table就无法继续使用了。

为什么TREEIFY_THRESHOLD的取值是8

理想状态下,在默认装填因子为0.75时,容器中的节点遵循参数λ=5的泊松分布。链表中的元素达到8个的概率是非常低的。

为什么UNTREEIFY_THRESHOLD的取值是6(而不是7)

如果为7,删除一个元素就会转换成链表,插入一个元素又会转换成红黑树。

从CPU-寄存器-高速缓存-内存说起

  • 问题:CPU不可能只靠寄存器来完成运算任务,但CPU的速度和内存的存取速度相差太远。
  • 解决:引入高速缓存来缓和CPU速度和内存存取速度的差异。CPU直接独写高速缓存,运算结束再将数据从缓存同步回内存中。
  • 新的问题:缓存一致性。多个CPU有自己的高速缓存,但是他们共享同一个内存,如果多个CPU的运算涉及同一个内存区域将产生数据的不一致问题。此时需要引入缓存一致性协议。
    • 处理器对内存的读写操作的执行顺序,不一定与内存实际发生的独写顺序一致。

Java内存模型

  • 一组规范,并不真实存在,定义了一组规则,定义了程序中各个变量的访问方式(确认一段程序的执行轨迹是否是合法的)。
  • 检查程序执行轨迹的每个读操作,根据一些规则检查读操作观察到的写操作是否是有效的。
  • JMM的实现可以是任意的,只要生成的代码的执行结果能够根据JMM的规则进行预测即可(松规范,给编译器/JVM实现者进行优化的空间,比如指令重排序或者移除不必要的同步)。

JMM的规定

  • 所有共享变量(不包括局部变量,局部变量线程私有)都存储于主内存。共享变量:实例变量、类变量(静态字段)、数组元素。
  • 每一个线程还在自己的工作内存中保留了被线程使用的变量的工作副本。
  • 线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量。
  • 不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成。
  • intra-thread semantics: 保证重排序不会改变单线程内的程序执行结果。
  • 对指令的重排序:
    • as-if-serial:不能改变单线程程序的语义,重排序后单线程程序的执行结果不能被改变。
    • 不存在数据依赖性的情况下,处理器可以进行重排序
      • 两个操作访问同一个变量,且其中一个操作为写操作,此时两个操作之间存在依赖性。

Java内存模型的三大特性(JMM在多线程环境下的三个问题)

  1. 可见性:一个线程修改了共享变量的值,其他线程能够立即得知这个修改。而单纯遵照JMM的规定,多线程程序中单个线程并不一定能马上获知其它线程对共享变量的更改。JMM通过在变量修改后将值同步回主内存,在变量读取前从主内存刷新变量值来实现可见性。
    • volatile关键字:
    • synchronized关键字:进入synchronized代码块前,线程会获得锁,清空工作内存,从主内存拷贝共享变量最新的值到工作内存成为副本。进入synchronized代码后,会将修改后的副本的值刷新回主内存中,线程才会释放锁。
    • final关键字
  2. 原子性(?):
  3. 有序性
    • 进行指令重排的时候必须考虑数据的依赖性。
    • 在多线程程序中,不同线程之间存在控制依赖关系重排序可能会改变程序的执行结果。
    • 举例:
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      public class ControlDependency {
      int a = 0;
      boolean flag = false;

      public void init() {
      a = 1; // 1
      flag = true; // 2
      }

      public void use() {
      if (flag) { // 3
      int i = a * a; // 4
      }
      }
      }
      在单线程环境下,语句1、2和语句3、4都不存在数据依赖性,因此指令会被重排序。但是在编译器在判断if语句块时会对语句块的内容进行猜测,因此会存在先执行int i = a * a;,然后再判断flag。如果另一线程在执行init时将指令重排序,先执行flag = true,然后又发生了线程切换,这时a的值为0,flag的值却为true。这样就会得到错误的结果int i = 0;

怎么解决JMM在多线程环境下的问题

  1. 插入内存屏障禁止指令重排序。(volatile
  2. 设置临界区。(synchronized):JMM允许临界区内的代码重排序,不允许临界区内的代码逃逸到临界区之外。
  3. Happens-Before语义:如果一个操作的执行结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。但这并不意味之前一个操作必须在后一个操作之前执行。Happens-before语义类似于单线程环境下的as-if-serial语义,它保证正确同步的多线程程序执行结果不被改变。
    • 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作
    • 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
    • volatile变量规则:对一个volatile字段的写操作happens-before于任意后续对这个volatile域的读。
    • 传递性:A happens-before B ,B happens-before C,那么A happens-before C
    • start()规则:如果线程A执行ThreadB.start(),那么A线程的start() happens-before于线程B中的任意操作
    • join()规则:如果线程A执行ThreadB.join(),那么线程B中的任意操作happens-before于线程A从ThreadB.join()成功返回
    • 线程中断规则:对线程interrput方法的调用happens-before于被中断线程的代码检查到中断事件的发生。

  • [] Netty线程模型
  • [] Zero Copy

反应器模式

  • 反应器Reactor: 负责查询IO事件。检测到一个IO事件时,将其发送给相应的Handler处理器去处理。这里的IO事件就是NIO中选择器监控的通道IO事件。
  • Handler处理器:与IO事件绑定,负责IO事件的处理。例如,完成真正的连接建立、通道(channel)的读取、处理业务逻辑、负责将结果写出到通道等。

单线程的Reactor模式

  • Reactor和Handler在同一线程中运行。
  • 相对于传统的多线程Blocking-IO,免去了线程切换的成本
  • 缺点:Reactor和Handler都在同一线程上执行。如果一个Handler被阻塞,其它所有的Handler都得不到执行。假如负责监听连接的Handler和负责数据输入输出的Handler都在同一个线程上,负责监听连接的Handler可能会被阻塞,此时整个服务都不能接受新的连接。
  • 思考:为什么Redis使用了单线程的Reactor模式?
    • Redis是一个服务器,Redis的数据操作多数都在内存中,因此Handler的操作耗时应该非常少
    • 多个线程切换需要线程切换开销,如果Handler并不需要很长的阻塞时间,使用多线程模型可能弊大于利
    • Redis是一个数据库,如果使用多线程的IO模型,多个线程可能会对同一个数据进行操作,此时需要引入同步机制,而同步机制的开销是相当大的。使用单线程模型,天然保证了单个Redis的数据库操作的原子性。

多线程的Reactor模式

  1. 升级Handler:使用线程池
  2. 升级Reactor:引入多个Selector,将单个Reactor线程拆分为多个SubReactor线程,每个SubReactor线程使用一个Selector。

VS 生产者消费者模式

反应器模式没有专门的队列去缓冲存储IO事件

VS 观察者模式

在Reactor模式中一个事件绑定到了一个Handler上,每个IO事件被查询后Reactor会将事件分发给所绑定的Handler。而观察者模式中同一个事件(主题)可以被订阅过的多个观察者处理。

Reactor的优缺点

优点:

  • 响应快,不会被单个连接的同步IO阻塞整个服务
  • 编程相对简单
  • 可扩展:增加Reactor线程的个数

缺点:

  • 增加一定复杂性
  • 需要OS底层IO多路复用的支持

Netty

Netty中的Reactor

NioEventLoop类

  • 一个NioEventLoop拥有一个线程,负责一个Java NIO Selector上的IO事件轮询。一个Reactor可以注册多个netty channel。一个EventLoop相当于一个(Sub)Reactor。
  • EventLoopGroup:线程组,多线程版本的反应器,其中的每一个EventLoop为一个SubReactor。

Netty通道(Channel)

  • Netty channel(TCP服务器里主流的就是NioSocketChannel)封装的就是Java NIO通道。Java NIO通道对应的就是OS底层的文件描述符(File Descriptor, fd)。
  • 连接监听型socket描述符:接受客户端的socket连接。对应ServerSocketChannel(Java NIO)和NioServerSocketChannel(Netty)
  • 传输数据型socket描述符:负责传输数据,在客户端和服务端都会有一个与一条TCP连接相对应的socket描述符进行数据传输。对应SocketChannel(Java NIO)和NioSocketChannel(Netty)
  • 有接受关系的NioServerSocketChannelNioSocketChannel可以被称为亲子(Parent - Child)通道

Netty的入站处理和出站处理

  • 通道中发生IO事件 -> 被EventLoop查询到 -> 分发给实现了ChannelInboundHandler处理器,并调用该处理器的对应处理方法(比如channelRead方法,这个事件在pipeline中可以通过pipeline上下文进行冒泡),进行一系列业务处理 -> 如果事件超出管道顶端,则会被抛弃,也可以通过事件冒泡的方式传递到出站处理器,通常由出站处理器生成或者处理出站流量 -> 事件被出站处理器处理 -> 如果IO事件到达了出站管道的底部,则IO事件会交由与该channel相关联的线程进行处理,这个IO线程通常会执行实际的IO操作。

Netty管道(pipeline)

  • Handler和Reactor是多对多的关系,一个Handler可以处理多个Reactor上分发的IO事件,一个Reactor上可以使用多个Handler来处理IO事件。多个Handler可以组成一个pipeline。
  • 一个Netty Channel拥有一条Pipeline。
  • IO事件在Pipeline上进行双向流动,入站时按顺序由实现了ChannelInboundHandler的处理器处理,出站时按逆序由实现了ChannelOutboundHandler的处理器处理。一个Handler可以同时实现这两个接口,这样出入站都会参与处理。

Netty脚手架(Bootstrap)

ORM框架

  • 将Java Bean转换成行记录
  • 将行记录转换为Java Bean

Hibernate

  • 通过Entity标注实体类,可用@Table(name="...")来标识对应的表名。通过@Column(...)建立Java Bean字段和数据库表的列的对应关系
  • 实体类之间可以有继承关系: 父类使用@MappedSuperClass标识
  • 提供自动生成的插入/删除/修改(update)/查询(findByExample/findByCriteria/HQL,使用类名和属性名,由Hibernate转换成实际的表名和列名)
  • ORM如何将对Java Bean的修改反应到数据库中(调用update时如何确定哪些字段更新了)
    • 使用代理模式,从ORM读出的对象是代理对象,原对象的每个属性都附加一个标志变量说明这个对象是否被修改。

MyBatis

  • 半自动ORM:可以完成Java Bean到数据行和数据行到Java Bean的双向转换,但是SQL需要用户自行编写。
  • 属性名与列名不一致?:添加ResultMap(注解或XML)

  • [x] SpringMVC原理

Spring框架

  • 多模块的集合
    • 核心容器(IOC)
    • 数据访问
    • Web
    • AOP

常用注解

@Component和@Bean

  • @Component作用于类,使用该注解的类通常会通过classpath扫描来自动侦测并被装配到Spring容器中。可以通过@ComponentScan来定义要扫描的路径,以及定义排除规则。
  • @Bean作用于方法,说明标有该注解的方法中会产生一个Bean。使用第三方库的时候,如果需要将第三方库的类装配到Spring容器中,可以定义一个产生该类的方法并附加@Bean注解。同时,使用@Bean可以实现根据不同条件进行不同的装配操作(在方法中编写条件代码),比@Component相对灵活。

如何将一个类声明为Spring的Bean

  • @Component: 通用
  • @Repository: 对应持久层(DAO)
  • @Service: 对应服务层
  • @Controller: 对应控制层
  • @Configuration: 配置类
  • 名称:@....(value = "...")

@Controller, @RestController

  • 都是表明某类是一个Controller的注解。Dispatcher会扫描使用了这两个注解的类的方法,并检查该方法是否使用了@RequestMapping@GetMapping一类的注解。
  • @Controller一般用于返回一个视图(一个页面,可以是使用模板引擎进行渲染的页面)。如果要返回JSON或XML格式的数据,需要搭配使用@ResponseBody注解。如果处理器方法被注解@ResponseBody的话,该方法的返回类型会通过适当的Converter转换后写到HttpServletResponse中,而不是被当成视图处理。
  • @RestController,是@Controller@ResponseBody的结合,只返回对象,对象数据直接以JSON或XML形式写入HTTP响应中。

属性注入:@Autowired @Qualifier @Resource

  • @AutoWired:根据类型进行自动装配
  • @Qualifier: 根据属性名称进行注入,需要和@Autowired配合使用。@Qualifier(value = "...")
  • @Resource: 可以根据类型注入,也可以根据名称注入
  • @Value:注入普通类型属性, @Value("classpath:/...")

前后端传值

  • @PathVariable: 路径参数
  • @RequestParam: 查询参数(Query String)
  • @RequestBody: 读取Request请求。Content-Typeapplication/json格式时,接收到数据之后会自动将数据绑定到Java对象上去。系统使用HttpMessageConverter或自定义的Converter将body中的json字符串转换为Java对象。

读取配置信息

  • @PropertySource("app.properties") + @Value("${app.property:defaultValue}")
  • @ConfigurationProperties(prefix = "..."): 读取配置信息并与Bean绑定

Json数据处理

  • @JsonIgnoreProperties({"字段名"}): 过滤掉特定字段不返回或者不解析
  • @JsonIgnore: 同上,不过直接作用于类的属性上
  • @JsonFormat: 格式化JSON数据
  • @JsonUnwrapped: 扁平化对象

IOC

怎么理解IOC

IoC(Inverse of Control:控制反转)是一种设计思想,就是 将原本在程序中手动创建对象的控制权,交由Spring框架来管理。IoC 容器是 Spring 用来实现 IoC 的载体, IoC 容器实际上就是个Map(key,value),Map 中存放的是各种对象。这样可以很大程度上简化应用的开发,把应用从复杂的依赖关系中解放出来。 IoC 容器就像是一个工厂一样,当我们需要创建一个对象的时候,只需要配置好配置文件/注解即可,完全不用考虑对象是如何被创建出来的。

  • 传统的new关键字进行手动装配存在一定困难
    • 实例化组件的过程是复杂的,某些组件需要读取配置才能完成实例化
    • 组件之间存在依赖关系,但多个组件依赖一个组件时,很多时候只需要依赖组件的单个实例,没有必要多次实例化。而实现这一点需要理清组件之间的依赖关系,以确定恰当的实例化顺序,这对于手动装配而言是麻烦的
    • 有时候组件需要进行销毁以释放资源,但销毁组件时需要确定所有依赖该组件的其他组件也已经被销毁
  • 因此IOC容器的职责就是
    • 负责创建组件
    • 负责根据依赖关系组装组件
    • 按照依赖顺序正确地销毁组件
  • Spring IOC的无侵入性:组件无需要实现框架特定的接口。因此编写完成的组件可以自动装配也可以手动装配。并且测试组件的时候可以单独进行测试。
    资源(对象)不由使用和提供资源的双方管理,而由第三方管理,集中式的管理容易配置,容易管理,也降低了资源提供方和使用方的耦合度。使得通过配置文件而不是在代码里硬编码(hardcode)的方式来实例化对象和装配对象图。
  • 本质上,IOC容器是个Map。IOC容器可以这样完成自动装配
    • BeanFactoryApplicationContext通过XML配置文件或classpath扫描获得所有的附加了@Bean的方法或者附加了@Component的注解,获得所有Bean的类名。通过对类名使用Class.forName可以完成类加载
    • BeanFactory:加载配置时不创建对象,第一次通过getBean获取Bean时才会创建对象。ApplicationContext:加载配置时就创建对象

Spring Bean管理

Bean的作用域

Spring IOC容器原本支持的作用域只有singleton和prototpye。其它作用域是后续框架的增强。

  • singleton:默认是单例的
  • prototype:每次获取bean时都会创建一个新的bean实例
  • request:每一次HTTP请求都会生成一个新的bean,仅在当前HTTP request内有效
  • session:每一次HTTP请求都会生成一个新的bean,仅在当前HTTP session内有效

单例bean的线程安全问题

  • 多个线程操作同一个单例对象时对这个对象的成员变量的写操作存在线程安全问题。
  • 一般情况下,Controller、Service、Dao等Bean都应该是无状态的,不保存数据的Bean即使被多线程使用也是安全的。
  • 如果需要成员变量,可以将成员变量保存在ThreadLocal中。或者将Bean的作用域改为prototype

Bean的生命周期

  • 实例化:实例化一个Bean对象
  • 属性赋值:为Bean设置相关属性。如果该Bean依赖其它Bean,则获取对其它bean的引用
  • 初始化:可能的Aware相关接口,BeanPostProcessor的前后置处理,是否实现InitializingBean接口,自定义的init-method@PostConstruct)(如果Bean有定义初始化方法,则调用这些初始化方法)
  • 销毁:自定义的destroy-method(容器关闭的时候,如果bean调用了销毁方法,则调用销毁方法@PreDestroy

    常用扩展点(生命周期钩子?)

    影响多个Bean的接口:后置处理器
  • 实现了这些接口的Bean会切入到多个Bean的生命周期中。
  • BeanPostProcessor:初始化阶段的前后,Bean实例会被传入这个后置处理器的方法。同时会返回一个Bean。String AOP可以在此处完成对Bean的增强,返回代理对象
    • postProcessBeforeInitialization
    • postProcessAfterInitialization
  • InstantiationAwareBeanPostProcessor:实例化阶段的前后
    只调用一次的接口

Spring中的设计模式

  • 工厂模式:通过BeanFactoryApplicationContext创建对象
  • 代理模式:Spring AOP
  • 单例模式:Spring中的单例Bean

SpringMVC

为什么要使用MVC设计模式

  • 将数据模型定义、控制逻辑和表现逻辑分离,降低各个模块之间的耦合度。
  • 视图:呈现模型,展示数据,直接与用户交互
  • 控制器:取得用户的输入,解读其对模型的操作,据此改变视图的状态、改变视图的显示
  • 模型:模型封装持有的数据、状态和业务逻辑,不需要对控制器和视图的存在进行感知,只提供操作和检索状态的接口,并发送状态改变通知给观察者。

MVC vs MVVM

  • MVC: M封装数据和逻辑,V提供展示,C进行控制:V向C请求修改Model,C帮V调用Model的操作,得到Model的反馈后用新的数据更新V。
  • MVVM:VM完成V与M的双向绑定。直接对V上的数据进行修改,VM会自动将对V的修改同步到M上。反之对M进行修改,VM会自动将对M的修改同步到V上,表现为用户界面的变化。

工作原理

  • 客户端发送请求,通过DispatcherServlet接收请求。
  • DispatcherServlet根据请求信息,从HandlerMapping查找对应的HandlerAdapter
  • HandlerAdapter相关的Handler调用真正的处理器来处理请求,完成相应的业务逻辑。
    • 为什么要区分HandlerHandlerAdapter
    • HandlerAdapter相当于一个代理。
    • 我们编写的Controller希望能够将路径参数、URL参数和请求体都作为方法的参数传入。因此HandlerAdapter可以将HttpServletRequest中的URL参数、请求体等进行解析,然后使用反射机制调用Handler的方法,实现这一目的。
  • 处理完请求后,如果是RESTful接口,则将返回数据通过适当的转换器转换后写到HttpServletResponse中。
  • 如果不是一个RESTful接口,处理完业务后会返回一个ModelAndView对象。

Spring 三级缓存

  • 一级缓存:singletonObjects:单例池,存放已经经历了完整生命周期的Bean(已经初始化好的Bean)
  • 二级缓存:earlySingletonObjects:存放早期暴露出来的Bean对象,生命周期未结束(未完成初始化,属性未填充完)
  • 三级缓存:singletonFactories:存放可以生成Bean的工厂
    springCache
  • 四大方法:
    • getSingleton
      • 一级缓存没有,并且当前对象并不是创建过程中(isSingletonCurrentlyInCreation返回false)就去二级缓存找
      • 二级缓存没有,就去三级缓存找
      • 三级缓存有的话(能够获得对应的FactoryBean),就通过factory创建一个原始Bean,放到二级缓存,并删除三级缓存中的factory
    • doCreateBean
    • populateBean
    • earlySingletonObjects
  • Spring创建Bean的过程:创建原始的bean对象,然后填充对象属性进行初始化
  • 每次创建单例Bean之前先在一级缓存中检查是否已经创建过

循环依赖问题

问题

  • 多个Bean之间互相依赖,形成闭环。默认B的单例Bean中,属性互相引用的场景。
  • 构造方法注入,setter方法注入
  • 构造方法注入可能导致循环依赖问题。并且坚持使用构造方法注入是无法解决的。
    • 注:一般情况下推荐使用构造注入
    • 使用基于setter方法的注入代替构造方法注入
    • 只要注入方式是setter方法注入,且Bean为单例Bean(@Component),则能够解决循环依赖问题
    • 原型(prototpye, @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE), @Scope("prototype"))的场景无法支持循环依赖,会报错BeanCurrentlyInCreationException

解决

  • Spring内部通过三级缓存解决循环依赖:DefaultSingletonBeanRegistry
  • 单例Bean:三级缓存提前暴露,依靠Bean的“中间态”概念,中间态指的是已经实例化但没有初始化的状态
  • 非单例Bean:每次都要获取一个新的对象,要重新创建,没有缓存
  • 创建单例Bean A的原始对象后(未填充属性),将其放入三级缓存,然后开始填充对象属性。此时发现Bean A需要依赖Bean B,顺序查找三级缓存未能找到Bean B,于是进行BeanB的创建。经过相似的过程后会发现创建Bean B需要Bean A
  • 此时Bean B在第三级缓存中查找到Bean A的原始对象,直接将原始对象注入Bean B(提前暴露Bean A),完成对Bean B的创建。
  • Bean B创建完毕后Bean A的创建流程即可继续,完成属性填充的工作。
    SpringCircularDependency

为什么一定要用三级缓存?

  • 维持Spring Bean的生命周期

AOP

怎么理解

能够将那些与业务无关,却为业务模块所共同调用的逻辑或责任(例如事务处理、日志管理、权限控制、统一的错误处理等)封装起来,便于减少系统的重复代码,降低模块间的耦合度,并有利于未来的可拓展性和可维护性。

原理

Spring AOP基于动态代理。如果代理对象实现了某个接口,使用JDK动态代理。否则使用CGLIB生产一个被代理对象的子类作为代理。
SpringAOPProcess

Spring AOP和AspectJ AOP的区别

  • Spring AOP无论是使用JDK提供的Proxy还是CGLIB,本质都是动态代理,在运行时对类进行增强。而AspectJ AOP其实使用了静态代理的方法,在编译字节码时在方法的周围加上业务逻辑,是编译期通过操作字节码完成静态织入。
  • AspectJ的功能更强大,同时性能更好,因为对方法的增强在编译期就已经完成了。

常用注解

  • @Before
  • @After
  • @AfterReturning
  • @AfterThrowing
  • @Around

AOP的执行顺序

  • 正常执行:环绕Before -> Before -> AfterReturn -> After -> 环绕After
  • 异常流程:环绕Before -> Before -> AfterThrowing -> After

Spring事务

  • 编程式事务:TransactionTemplateTransactionManager
  • 声明式事务:基于XML、基于注解
    • 实现原理:动态代理

      Spring事务的隔离级别(isolation)

  • TransactionDefinition.ISOLATION_DEFAULT: 使用后端数据库默认的隔离级别,Mysql 默认采用的 REPEATABLE_READ隔离级别 Oracle 默认采用的 READ_COMMITTED隔离级别.
  • TransactionDefinition.ISOLATION_READ_UNCOMMITTED: 最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读
  • TransactionDefinition.ISOLATION_READ_COMMITTED: 允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生
  • TransactionDefinition.ISOLATION_REPEATABLE_READ: 对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。
  • TransactionDefinition.ISOLATION_SERIALIZABLE: 最高的隔离级别,完全服从ACID的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。但是这将严重影响程序的性能。通常情况下也不会用到该级别。

Spring事务的传播(propagation)

加入当前事务

  • TransactionDefinition.PROPAGATION_REQUIRED: 如果当前存在事务,则加入该事务;如果当前没有事务,则创建一个新的事务。
  • TransactionDefinition.PROPAGATION_SUPPORTS: 如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务的方式继续运行。
  • TransactionDefinition.PROPAGATION_MANDATORY: 如果当前存在事务,则加入该事务;如果当前没有事务,则抛出异常。(mandatory:强制性)

挂起当前事务

  • TransactionDefinition.PROPAGATION_REQUIRES_NEW: 创建一个新的事务,如果当前存在事务,则把当前事务挂起。
  • TransactionDefinition.PROPAGATION_NOT_SUPPORTED: 以非事务方式运行,如果当前存在事务,则把当前事务挂起。
  • TransactionDefinition.PROPAGATION_NEVER: 以非事务方式运行,如果当前存在事务,则抛出异常。

其他情况

  • TransactionDefinition.PROPAGATION_NESTED: 如果当前存在事务,则创建一个事务作为当前事务的嵌套事务来运行;如果当前没有事务,则该取值等价于TransactionDefinition.PROPAGATION_REQUIRED。

@Transcational(rollbackFor = Exception.class)

  • 让Spring事务遇到非运行时异常时也回滚。

事务超时属性(timeout)

  • TransactionDefinition中以int类型timeout表示超时时间

事务只读属性(readOnly)

自调用问题

若同一类中的其他没有 @Transactional 注解的方法内部调用有 @Transactional 注解的方法,有@Transactional 注解的方法的事务会失效。
因为Spring AOP的实现原因,当含有@Transactional注解的方法在类以外被调用的时候,Spring事务管理才生效。

TODOS:

  • [] SpringBoot的启动流程

SpringBoot帮我们干了什么

依赖管理

  • 父项目完成依赖管理(spring-boot-starter-parent)。父项目几乎声明了常用的所有依赖的版本号
  • 开发时导入starter场景启动器:对于一个场景,引入starter依赖会引入该场景相关的所有常规依赖
    • 所有场景启动器都依赖spring-boot-starter
  • 无需关注版本号,完成自动版本仲裁
    • 查看spring-boot-dependencies中的依赖标签名。也可以在项目中的<properties>标签中自定义依赖的版本号

自动配置

  • 对于一个场景中的各种依赖会提供默认配置,开箱即用(例如web.xml中的各种设置,例如字符编码…etc)
  • 默认的包结构(包扫描的目录:主程序所在的包及其子包中的所有类都会被扫描
  • 各种配置拥有默认值(默认端口,默认值…),配置类持有这些默认值,而在SpringBoot类的项目配置文件中(application.properties/application.yml)进行设置可以将这些值绑定到对应的配置类上,配置类会被加载到容器中发挥作用。
  • 按需加载自动配置项:引入的时候自动配置才会开启
    • 所有的自动配置功能都在spring-boot-autoconfigure

底层注解

@SpringBootApplication

默认加在主类上,是@SpringBootConfiguration(其实就是@Configuration, 允许在Spring上下文中注册额外的Bean或导入其它配置类)、@EnableAutoConfiguration(启动Springboot自动配置机制)、@ComponentScan(扫描Bean,扫描该类所在包下所有的类)的集合。

@Configuration

  • 声明配置类,作用如beans.xml
  • 创建Bean:
    • 在类中创建方法并附加@Bean注解,方法名就是组件ID,返回类型就是组件类型,返回值就是组件实例。默认为单例Bean
  • 默认proxyBeanMethods = true:代理Bean的方法。可以说明@Configuration中的方法是被代理的。(重复直接调用单例Bean的创建方法不会返回不同对象,说明方法已经被代理)
    • Full配置与Lite配置:Full模式中配置类的proxyBeanMethods = true,Lite模式中为false

@Import(Class<?>[])

  • 向容器中导入指定的类的实例

@Conditional

  • 满足条件时才进行装配

@ImportResource

  • 从classpath中搜索配置文件,注入配置文件中描述的Bean

@ConfigurationProperties

  • 从配置文件中读取配置,绑定到容器中的Bean的属性上
  • @EnableConfigurationProperties:附加在配置类上,开启指定类的属性配置功能,同时把这个类的对象加入容器中。主要在为第三方包的类进行配置绑定时使用。(因为没办法在第三方的类加注解)

自动装配原理

核心注解@EnableAutoConfiguration

@AutoConfigurationPackage

AutoConfigurationPackage

@Import({AutoConfigurationImportSelector.class})

  • 其中包含注解@Import({AutoConfigurationImportSelector.class})
  • AutoConfigurationImportSelector 类实现了 ImportSelector接口,也就实现了这个接口中的 selectImports方法,该方法主要用于获取所有符合条件的类的全限定类名,这些类需要被加载到 IoC 容器中
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    private static final AutoConfigurationEntry EMPTY_ENTRY = new AutoConfigurationEntry();

    AutoConfigurationEntry getAutoConfigurationEntry(AutoConfigurationMetadata autoConfigurationMetadata, AnnotationMetadata annotationMetadata) {
    //<1>.
    if (!this.isEnabled(annotationMetadata)) {
    return EMPTY_ENTRY;
    } else {
    //<2>.
    AnnotationAttributes attributes = this.getAttributes(annotationMetadata);
    //<3>.
    List<String> configurations = this.getCandidateConfigurations(annotationMetadata, attributes);
    //<4>.
    configurations = this.removeDuplicates(configurations);
    Set<String> exclusions = this.getExclusions(annotationMetadata, attributes);
    this.checkExcludedClasses(configurations, exclusions);
    configurations.removeAll(exclusions);
    configurations = this.filter(configurations, autoConfigurationMetadata);
    this.fireAutoConfigurationImportEvents(configurations, exclusions);
    return new AutoConfigurationImportSelector.AutoConfigurationEntry(configurations, exclusions);
    }
    }
  1. 判断自动装配开关是否打开。默认spring.boot.enableautoconfiguration=true,可在 application.properties 或 application.yml 中设置
  2. 用于获取EnableAutoConfiguration注解中的 exclude 和 excludeName。为排除
  3. 获取需要自动装配的所有配置类,读取META-INF/spring.factories(所有引入的starter依赖中都需要定义/META-INF/spring.factories,才能被SpringBoot的自动装配机制捕捉到),查找出所有的XXXAutoConfiguration
  4. 对自动配置器进行筛选,在XXXAutoConfiguration类中的@ConditionalOnClass中,规定只有相关的类存在的时候(也就是classpath下有指定类的时候),才会将加载器实例化并注入IOC容器中,进而完成下一步的自动装配。

手写Starter

  1. 命名最好是XXX-spring-boot-starter,而不是spring-boot-starter-XXX,后者是SpringBoot官方的Starter的命名方式
  2. 引入依赖:spring-boot-starter-parent, spring-boot-starter
  3. 创建自动配置器:带有@Configuration注解的类,这样其中的方法可以向IOC容器注入Bean。需要为类或者方法附加@ConditionalOnClass注解,否则无论如何都会为Spring注入Bean。
  4. 在工程的resource目录下创建/META-INF/spring.factories文件,文件内容包含org.springframework.boot.autoconfigure.EnableAutoConfiguration=\以及自动装配器的全限定类名。
  5. 最后在SpringBoot项目中引入该starter,即可实现自动装配。

  • [] CAP理论彻底理清
  • [] BASE
  • [] 常见中间件在CAP理论中的定位:
    • [] MySQL Cluster
    • [] Redis Cluster
    • [] Redis Sentinel
    • [] Zookeeper
    • [] Eureka
    • [] Nacos
    • [] HDFS(?)
  • [] 常见算法(优缺点及其原因
    • 2PC协议/3PC协议
    • Paxos简单情况
    • ZAB
    • Raft

多机器的时钟同步

  • 分布式环境下多台机器的时间流动可能是不一样的,需要一种机制来进行时钟同步,在多个机器间确定事件的发生顺序
  • 全序:任意两个元素都有二元关系,可比较
  • 偏序:只有部分元素可以比较,无法确定所有元素的准确顺序
  • 在单节点系统上维持全序关系是容易的,但是在分布式系统上维持全序关系需要付出代价。通信的代价是昂贵的,时间的同步是非常困难并且脆弱的。

全局时间

  • 理想世界,每个节点使用同一个有完美准确度的时钟

本地时间

逻辑时间戳

  • “逻辑时间(logical time)”而非真实时间来记录因果关系:使用计数器和通信机制来判断事件发生的顺序
  • 无法得知有关时间间隔的信息,也无法使用超时机制

向量时间戳

Simple Lamport Clock

进程工作 -> 计数器自增
发送消息 -> 携带计数器值
接收消息 -> 合并计数器

  • 偏序关系:若timestamp(a) < timestamp(b),则a可能在b之前发生,也可能无法比较。因为一个Lamport clock只能携带1条时间线的信息,因此可能发生“明明是同时发生的事件,却被排序”的情况。

Vector Clock

FLP impossiblity result

  • 假设
    • 节点只会fail by crashing,不存在拜占庭式错误
    • 网络是可靠的
    • 其它异步系统模型所需的关于时间的假设
      • 各个进程行进的速率是独立的
      • 没有上界
      • 没有可用的时钟
  • 结论
    • 即使在消息不会丢失,最多一个进程fail且只会fail by crashing的条件下,异步系统模型中也不存在能够解决共识问题(consensus problem)的确定性算法。
    • 假设这样的算法存在,则总能设计出一种执行这种算法的方式,通过delaying message来让系统remain undecided(“bivalent”)
    • 这个结论的重要意义是它强调了在异步系统模型的假设下(no uppder bound),设计解决共识问题的算法必须考虑一种折衷:要么放弃safety,要么放弃liveness。

CAP theorem

三类属性

  • 一致性Consistency:所有节点在同一时间都可以访问到同样的数据(see the same data)
  • 可用性Availability:节点的failure不影响没有fail的节点继续工作
  • 分区容错性Partition tolerance:即使出现了因为网络问题(Partition - communication break)导致的消息丢失,系统还是能继续工作
  • 最多只有两种属性能够同时满足。(事实上,这并不是严格的“3选2”问题,某些时候属性之间存在tradeoff)

    三系统类型

  • CA:保证一致性可用性, full strict quorum protocols,例如two-phace commit(2PC)
    • 一种强一致性模型,无法区分节点不可用和网络问题,只能通过停止接收写操作来避免数据分歧
    • quorum protocol:基于鸽巢原理的算法,保证数据冗余和最终一致性
      • 分布式系统中的每一份数据拷贝对象都被赋予一票。每一个读操作获得的票数必须大于最小读票数(read quorum)(Vr),每个写操作获得的票数必须大于最小写票数(write quorum)(Vw)才能读或者写。如果系统有V票(意味着一个数据对象有V份冗余拷贝),那么最小读写票数(quorum)应满足如下限制:
        • Vr + Vw > V
        • Vw > V/2
      • 第一条规则保证了一个数据不会被同时读写。当一个写操作请求过来的时候,它必须要获得Vw个冗余拷贝的许可。而剩下的数量是V-Vw 不够Vr,因此不能再有读请求过来了。同理,当读请求已经获得了Vr个冗余拷贝的许可时,写请求就无法获得许可了。
      • 第二条规则保证了数据的串行化修改。一份数据的冗余拷贝不可能同时被两个写请求修改。
  • CP:保证一致性和分区容错性,majority quorum protocols,例如Paxos,Raft
    • 也是一种强一致性模型,通过对网络中断的两段实行不对称的行为来避免数据分歧。节点多的一侧可用,节点少的一侧不可用(属于哪种情况需要使用算法进行区分)。
  • AP:使用冲突化解(conflit resolution)的协议

导出结论

  • 应该积极考虑Partition Tolerance:“分布式系统必须保证分区容错性,基本上只能选择AP原则和CP原则”,“You can’t sacrifice partition tolerance”
  • 因此分布式系统中并不存在”CA”类别,当network partition发生时,分布式系统要么保证一致性,要么保证可用性。(单机系统可以存在CA类别,比如RDBMS)
  • 存在network partitions时,强一致性和高可用性存在冲突
  • 在一般情况下,强一致性和性能存在冲突。要保证强一致性,各个节点要在每个操作上进行通信和协商,这会导致很高的latency

强一致性与弱一致性模型

强一致性模型

  • 可线性化一致性:在可线性一致性的情况下,所有的操作似乎都按照与全局实时操作顺序一致的顺序原子地执行
  • 顺序一致性:在顺序一致性的情况下,所有操作似乎都是按照某种顺序原子地执行的,这种顺序与在单个节点上看到的顺序一致,并且在所有节点上都是相等的。

弱一致性模型

  • client-centric一致性模型:入了客户端或会话机制的一致性模型,保证客户不会访问到一个数据项的旧版本,通常可以在客户端增加额外的cache来实现
  • 最终一致性模型:如果停止对数据的值进行修改,经过”一段待定义的时间”后所有数据的副本都会达成一致得到某个相同的值。在此之前,多个数据的副本可能在”某些为定义的行为中”存在不一致的现象。
    • 因果一致性:如果A更新完数据通知了B,则B之后对数据的访问和修改必须基于A更新后的值。对于C则没有这样的限制。
    • Read your writes:节点A更新后总是能访问到自己更新过的最新值。

BASE理论

  • Basically Available: 基本可用。出现故障时,可能出现响应时间变慢或者部分功能降级,但是仍能提供服务。
  • Soft State: 软状态,允许系统中的数据存在中间状态,而不是要求多个节点的数据副本总是一致的。
  • Eventually Consistent: 最终一致性,软状态存在时间期限。时间期限过后应该保证所有的副本保持数据一致性。

分布式事务

  • 一种分布式共识的特殊情况
  • DBMS相较于文件系统的意义在于
    • 基于关系代数,实现对数据的一种结构化存储和结构化查询
    • 事务的支持 -> ACID

      2PC:尽量保证强(?)一致性

      流程:

  • 一阶段:
  1. 客户端向协调者发起事务。事务的协调者节点首先向所有的参与者节点发送Prepare请求。
  2. 接收到Prepare请求后,每一个参与者节点都会写入日志并各自执行与事务有关的数据更新(此时会对数据上锁),但是暂时不提交事务,而是想协调者节点返回“完成”消息。
  3. 协调者接收到所有参与者返回的消息后,分布式事务进入第二阶段。
  • 二阶段正常流程:
    • 成功流程:
      1. 如果协调者节点接收到的都是“可以准备提交事务”的消息,那么协调者节点会向所有参与者发送事务提交命令。
      2. 参与者接收到事务提交命令,进行事务的提交并释放锁资源。本地事务完成提交后,会向当前协调者返回“完成”消息。
      3. 协调者接收到所有事务参与者的反馈,分布式事务完成。
    • 失败流程:
      1. 在第一阶段协调者接收到某个参与者反馈的失败消息,说明该节点的事务执行不成功,必须回滚。
      2. 协调者节点向所有的参与者发送abort请求。接收到abort请求的参与者需要在本地进行事务的回滚操作。
    • 无论是成功还是失败,此时才会告知客户端事务执行的结果。

存在的问题

  • 一阶段中如果协调者发出Prepare请求,有可能收不到所有参与者的响应。此时可以引入重试-超时机制。如果超时,则直接回滚。
  • 二阶段第一步如果协调者发出提交请求或回滚请求,但是没有收到所有参与者的响应。则需要不断重发提交请求,直到收到全部协调者的响应。否则可能导致参与者节点的数据不一致。
  • 各个节点在事务执行过程中会持续占用数据库资源(在事务相关的数据上加锁),性能较差。
  • 协调者的单点故障问题
    • 发送Prepare请求之后协调者单点故障、发送提交事务/回滚事务之前单点故障、发送…之后单点故障…

3PC

流程

  • 一阶段同样发出Prepare请求,但是不让参与者立即执行事务,而是只需要返回是否能够准备。
  • 二阶段进行预提交,多出这一状态可以让参与者确认其它参与者都回应了协调者,表示可以进行后续事务。该阶段参与者会进行写日志和修改数据,不提交事务。
  • 三阶段进行事务提交,协调者发出提交事务请求,参与者响应。
  • 同样,不管哪一个阶段有参与者返回失败都会宣布事务失败。
  • 参与者超时:等待提交事务请求超时,参与者会直接提交事务(?)。而等待与提交命令超时的情况,则没有任何影响。

复制(Replication)

复制的时机来分类

同步复制

  • 请求 -> 阻塞,将操作复制到所有节点 -> 所有节点返回 -> 向客户端返回
  • 返回客户端之前状态变化会被系统中所有节点所知。
  • 性能差,耐用性保证强。

    异步复制

  • 请求 -> 立即响应,将更新数据保存在本机 -> 发送异步复制消息,联系其他节点 其他节点更新它们的副本
  • 性能高,对网络延迟的容忍度高,耐用性保证差,如果某个修改在给客户端发送响应后,在成功复制到从节点之前丢失了,那这个修改就永远丢失了。
  • 高可用性,但是单纯的lazy approach无法保证错误发生时能够读出之前写入的内容

按照副本一致性的强弱分类

维护单一副本(prevent divergence,single copy)

  • 尽可能地表现得像单一系统
  • 保证只有一个活动副本
  • 保证所有副本的一致性
  • 例子:异步主从复制、同步主从复制、2PC、Multi-Paxos,3PC,Paxos with leader election

    multi-master systems(risk divergence)

常见的复制算法:主从复制

  • 所有操作都在一个master server上进行,master server将所有操作序列化并形成local log,local log将被发送到所有backup server上形成replica
  • 丢失修改的两种情况
    • 异步复制导致:没来得及同步完成时master宕机,新slave成为master,旧master即使恢复了也会变成slave,无法将修改复制到其他节点
    • 脑裂:master和slave无法互相联系,联系不上master的slave选出了backup master,新旧master同时接收修改,导致丢失修改
      • 怎么解决?要求master必须与一定量的slave保持联系才可以接受写,否则拒绝写

主从复制下的一致性协议:2PC(2 Phase Commit)

  • 中心化的协议,需要单一的master/leader/coordinator角色
  • 过程 vote -> decision -> commit
  • 是一个CA算法,不保证Partition Tolerance

共识算法

Quorum机制

最极端的情况:Write All Read One

  • 当Client请求向某副本写数据时(更新数据),只有当所有的副本都更新成功之后,这次写操作才算成功,否则视为失败。
  • 写操作很脆弱,因为只要有一个副本更新失败,此次写操作就视为失败了。
  • 读操作很简单,因为,所有的副本更新成功,才视为更新成功,从而保证所有的副本一致。这样,只需要读任何一个副本上的数据即可。假设有N个副本,N-1个都宕机了,剩下的那个副本仍能提供读服务;但是只要有一个副本宕机了,写服务就不会成功。

W R N

  • 更新操作在W个副本中更新成功后才认为此次更新操作成功。更新后的数据为“成功提交”。
  • 对于读操作,至少要读R个副本才能读到此次更新的数据
  • W + R > N
  • 在保证强一致性的情况下,W越大,写操作的可用性越差,读操作的可用性越好,反之亦然。

如何判断是否是最新的?

一致性哈希

传统哈希方法的局限性

  • 通过哈希的方式确定某个数据或某个请求应该落到哪个节点。
  • 传统哈希方法在节点增加或减少的时候需要进行重哈希,这会导致大量key和节点的映射关系发生变化。 -> 如果是缓存的情况,此时会发生大量的缓存失效,造成缓存雪崩。

一致性哈希算法

特点

  • 保证了增加或减少服务器时让尽可能少的映射发生改变

    原理

  • 一致性哈希环:分布范围是[0, 2^32 - 1]
  • 对象和服务器放置在同一个哈希环上。
  • 对于每个对象,在哈希环上顺时针查找举例这个对象的哈希值最近的服务器。将这一服务器确定为该对象所属的服务器。
  • 对于服务器增加的情况,只有在哈希环上位于新增的服务器和该服务器的上一台服务器之间的对象需要进行重新分配。
  • 对于服务器减少的情况,只有原本分配到该台服务器上的对象需要进行重新分配,其它节点不会受影响。
  • 虚拟节点:将每台物理服务器虚拟为一组虚拟服务器,将虚拟服务器配置到哈希环上,然后维护虚拟服务器到物理服务器的映射。

雪崩问题

  • 如果哈希环上的一个服务器故障,原本属于该服务器的请求或数据访问就会全部转移到哈希环上的下一个服务器,这会造成下一个服务器的负载瞬间上升,有可能导致下一个服务器故障。
  • 解决:在下一个服务器之前增加虚拟节点,分担下一个服务器的压力。